You've already forked AstralRinth
forked from didirus/AstralRinth
Payouts code (#765)
* push to rebase * finish most * finish most * Finish impl * Finish paypal * run prep * Fix comp err
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::database::models::User;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::analytics::Download;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{PayoutStatus, RecipientStatus};
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::maxmind::MaxMindIndexer;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -12,12 +10,8 @@ use crate::routes::ApiError;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("admin")
|
||||
.service(count_download)
|
||||
.service(trolley_webhook)
|
||||
.service(force_reindex),
|
||||
);
|
||||
}
|
||||
@@ -143,174 +136,6 @@ pub async fn count_download(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TrolleyWebhook {
|
||||
model: String,
|
||||
action: String,
|
||||
body: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[post("/_trolley")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn trolley_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
mut payload: web::Payload,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(signature) = req.headers().get("X-PaymentRails-Signature") {
|
||||
let payload = read_from_payload(
|
||||
&mut payload,
|
||||
1 << 20,
|
||||
"Webhook payload exceeds the maximum of 1MiB.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut signature = signature.to_str().ok().unwrap_or_default().split(',');
|
||||
let timestamp = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
let v1 = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mac: Hmac<Sha256> =
|
||||
Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes())
|
||||
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(&payload);
|
||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
||||
|
||||
if &*request_signature == v1 {
|
||||
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
|
||||
|
||||
if webhook.model == "recipient" {
|
||||
#[derive(Deserialize)]
|
||||
struct Recipient {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub status: Option<RecipientStatus>,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("recipient") {
|
||||
if let Ok(recipient) = serde_json::from_value::<Recipient>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id FROM users WHERE trolley_id = $1",
|
||||
recipient.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = value {
|
||||
let user = User::get_id(
|
||||
crate::database::models::UserId(user.id),
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if webhook.action == "deleted" {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET trolley_account_status = NULL, trolley_id = NULL
|
||||
WHERE id = $1
|
||||
",
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET email = $1, email_verified = $2, trolley_account_status = $3
|
||||
WHERE id = $4
|
||||
",
|
||||
recipient.email.clone(),
|
||||
user.email_verified && recipient.email == user.email,
|
||||
recipient.status.map(|x| x.as_str()),
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut *transaction).await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(&[(user.id, None)], &redis).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if webhook.model == "payment" {
|
||||
#[derive(Deserialize)]
|
||||
struct Payment {
|
||||
pub id: String,
|
||||
pub status: PayoutStatus,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("payment") {
|
||||
if let Ok(payment) = serde_json::from_value::<Payment>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1",
|
||||
payment.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(payout) = value {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if payment.status.is_failed()
|
||||
&& !PayoutStatus::from_string(&payout.status).is_failed()
|
||||
{
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
payout.amount,
|
||||
payout.user_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE historical_payouts
|
||||
SET status = $1
|
||||
WHERE payment_id = $2
|
||||
",
|
||||
payment.status.as_str(),
|
||||
payment.id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(
|
||||
&[(crate::database::models::UserId(payout.user_id), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("/_force_reindex", guard = "admin_key_guard")]
|
||||
pub async fn force_reindex(
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -38,7 +38,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(delete_gallery_item)
|
||||
.service(project_follow)
|
||||
.service(project_unfollow)
|
||||
.service(project_schedule)
|
||||
.service(super::teams::team_members_get_project)
|
||||
.service(
|
||||
web::scope("{project_id}")
|
||||
@@ -526,36 +525,6 @@ pub async fn projects_edit(
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SchedulingData {
|
||||
pub time: DateTime<Utc>,
|
||||
pub requested_status: ProjectStatus,
|
||||
}
|
||||
|
||||
#[post("{id}/schedule")]
|
||||
pub async fn project_schedule(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
scheduling_data: web::Json<SchedulingData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let scheduling_data = scheduling_data.into_inner();
|
||||
v3::projects::project_schedule(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
web::Json(v3::projects::SchedulingData {
|
||||
time: scheduling_data.time,
|
||||
requested_status: scheduling_data.requested_status,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Extension {
|
||||
pub ext: String,
|
||||
|
||||
@@ -112,6 +112,7 @@ pub struct NewTeamMember {
|
||||
#[serde(default)]
|
||||
pub organization_permissions: Option<OrganizationPermissions>,
|
||||
#[serde(default)]
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
pub payouts_split: Decimal,
|
||||
#[serde(default = "default_ordering")]
|
||||
pub ordering: i64,
|
||||
|
||||
@@ -3,17 +3,14 @@ use crate::file_hosting::FileHost;
|
||||
use crate::models::projects::Project;
|
||||
use crate::models::users::{Badges, Role};
|
||||
use crate::models::v2::projects::LegacyProject;
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::{v2_reroute, v3, ApiError};
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
@@ -30,10 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(user_edit)
|
||||
.service(user_icon_edit)
|
||||
.service(user_notifications)
|
||||
.service(user_follows)
|
||||
.service(user_payouts)
|
||||
.service(user_payouts_fees)
|
||||
.service(user_payouts_request),
|
||||
.service(user_follows),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,6 +152,7 @@ pub async fn user_edit(
|
||||
bio: new_user.bio,
|
||||
role: new_user.role,
|
||||
badges: new_user.badges,
|
||||
venmo_handle: None,
|
||||
}),
|
||||
pool,
|
||||
redis,
|
||||
@@ -250,72 +245,3 @@ pub async fn user_notifications(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
v3::users::user_notifications(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[get("{id}/payouts")]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
v3::users::user_payouts(req, info, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeeEstimateAmount {
|
||||
amount: Decimal,
|
||||
}
|
||||
|
||||
#[get("{id}/payouts_fees")]
|
||||
pub async fn user_payouts_fees(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
web::Query(amount): web::Query<FeeEstimateAmount>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
v3::users::user_payouts_fees(
|
||||
req,
|
||||
info,
|
||||
web::Query(v3::users::FeeEstimateAmount {
|
||||
amount: amount.amount,
|
||||
}),
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
payouts_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PayoutData {
|
||||
amount: Decimal,
|
||||
}
|
||||
|
||||
#[post("{id}/payouts")]
|
||||
pub async fn user_payouts_request(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Json<PayoutData>,
|
||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
v3::users::user_payouts_request(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
web::Json(v3::users::PayoutData {
|
||||
amount: data.amount,
|
||||
}),
|
||||
payouts_queue,
|
||||
redis,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ use crate::models::projects::{Dependency, FileType, Version, VersionStatus, Vers
|
||||
use crate::models::v2::projects::LegacyVersion;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::{v2_reroute, v3};
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use validator::Validate;
|
||||
@@ -23,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(version_get)
|
||||
.service(version_delete)
|
||||
.service(version_edit)
|
||||
.service(version_schedule)
|
||||
.service(super::version_creation::upload_file_to_version),
|
||||
);
|
||||
}
|
||||
@@ -254,35 +252,6 @@ pub async fn version_edit(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SchedulingData {
|
||||
pub time: DateTime<Utc>,
|
||||
pub requested_status: VersionStatus,
|
||||
}
|
||||
|
||||
#[post("{id}/schedule")]
|
||||
pub async fn version_schedule(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(models::ids::VersionId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
scheduling_data: web::Json<SchedulingData>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
v3::versions::version_schedule(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
web::Json(v3::versions::SchedulingData {
|
||||
time: scheduling_data.time,
|
||||
requested_status: scheduling_data.requested_status,
|
||||
}),
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[delete("{version_id}")]
|
||||
pub async fn version_delete(
|
||||
req: HttpRequest,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::database::models::User;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::analytics::Download;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{PayoutStatus, RecipientStatus};
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::maxmind::MaxMindIndexer;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -12,12 +10,8 @@ use crate::routes::ApiError;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("admin")
|
||||
.service(count_download)
|
||||
.service(trolley_webhook)
|
||||
.service(force_reindex),
|
||||
);
|
||||
}
|
||||
@@ -143,174 +136,6 @@ pub async fn count_download(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TrolleyWebhook {
|
||||
model: String,
|
||||
action: String,
|
||||
body: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[post("/_trolley")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn trolley_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
mut payload: web::Payload,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(signature) = req.headers().get("X-PaymentRails-Signature") {
|
||||
let payload = read_from_payload(
|
||||
&mut payload,
|
||||
1 << 20,
|
||||
"Webhook payload exceeds the maximum of 1MiB.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut signature = signature.to_str().ok().unwrap_or_default().split(',');
|
||||
let timestamp = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
let v1 = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mac: Hmac<Sha256> =
|
||||
Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes())
|
||||
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(&payload);
|
||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
||||
|
||||
if &*request_signature == v1 {
|
||||
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
|
||||
|
||||
if webhook.model == "recipient" {
|
||||
#[derive(Deserialize)]
|
||||
struct Recipient {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub status: Option<RecipientStatus>,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("recipient") {
|
||||
if let Ok(recipient) = serde_json::from_value::<Recipient>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id FROM users WHERE trolley_id = $1",
|
||||
recipient.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = value {
|
||||
let user = User::get_id(
|
||||
crate::database::models::UserId(user.id),
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if webhook.action == "deleted" {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET trolley_account_status = NULL, trolley_id = NULL
|
||||
WHERE id = $1
|
||||
",
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET email = $1, email_verified = $2, trolley_account_status = $3
|
||||
WHERE id = $4
|
||||
",
|
||||
recipient.email.clone(),
|
||||
user.email_verified && recipient.email == user.email,
|
||||
recipient.status.map(|x| x.as_str()),
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut *transaction).await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(&[(user.id, None)], &redis).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if webhook.model == "payment" {
|
||||
#[derive(Deserialize)]
|
||||
struct Payment {
|
||||
pub id: String,
|
||||
pub status: PayoutStatus,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("payment") {
|
||||
if let Ok(payment) = serde_json::from_value::<Payment>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1",
|
||||
payment.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(payout) = value {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if payment.status.is_failed()
|
||||
&& !PayoutStatus::from_string(&payout.status).is_failed()
|
||||
{
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
payout.amount,
|
||||
payout.user_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE historical_payouts
|
||||
SET status = $1
|
||||
WHERE payment_id = $2
|
||||
",
|
||||
payment.status.as_str(),
|
||||
payment.id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(
|
||||
&[(crate::database::models::UserId(payout.user_id), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("/_force_reindex", guard = "admin_key_guard")]
|
||||
pub async fn force_reindex(
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod images;
|
||||
pub mod moderation;
|
||||
pub mod notifications;
|
||||
pub mod organizations;
|
||||
pub mod payouts;
|
||||
pub mod project_creation;
|
||||
pub mod projects;
|
||||
pub mod reports;
|
||||
@@ -49,6 +50,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(threads::config)
|
||||
.configure(users::config)
|
||||
.configure(version_file::config)
|
||||
.configure(payouts::config)
|
||||
.configure(versions::config),
|
||||
);
|
||||
}
|
||||
|
||||
745
src/routes/v3/payouts.rs
Normal file
745
src/routes/v3/payouts.rs
Normal file
@@ -0,0 +1,745 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::{get_user_from_headers, AuthenticationError};
|
||||
use crate::database::models::generate_payout_id;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::PayoutId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::payouts::{PayoutMethodType, PayoutStatus};
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use hyper::Method;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("payout")
|
||||
.service(paypal_webhook)
|
||||
.service(tremendous_webhook)
|
||||
.service(user_payouts)
|
||||
.service(create_payout)
|
||||
.service(cancel_payout)
|
||||
.service(payment_methods),
|
||||
);
|
||||
}
|
||||
|
||||
#[post("_paypal")]
|
||||
pub async fn paypal_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
payouts: web::Data<PayoutsQueue>,
|
||||
body: String,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let auth_algo = req
|
||||
.headers()
|
||||
.get("PAYPAL-AUTH-ALGO")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing auth algo".to_string()))?;
|
||||
let cert_url = req
|
||||
.headers()
|
||||
.get("PAYPAL-CERT-URL")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing cert url".to_string()))?;
|
||||
let transmission_id = req
|
||||
.headers()
|
||||
.get("PAYPAL-TRANSMISSION-ID")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing transmission ID".to_string()))?;
|
||||
let transmission_sig = req
|
||||
.headers()
|
||||
.get("PAYPAL-TRANSMISSION-SIG")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing transmission sig".to_string()))?;
|
||||
let transmission_time = req
|
||||
.headers()
|
||||
.get("PAYPAL-TRANSMISSION-TIME")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing transmission time".to_string()))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebHookResponse {
|
||||
verification_status: String,
|
||||
}
|
||||
|
||||
let webhook_res = payouts
|
||||
.make_paypal_request::<(), WebHookResponse>(
|
||||
Method::POST,
|
||||
"notifications/verify-webhook-signature",
|
||||
None,
|
||||
// This is needed as serde re-orders fields, which causes the validation to fail for PayPal.
|
||||
Some(format!(
|
||||
"{{
|
||||
\"auth_algo\": \"{auth_algo}\",
|
||||
\"cert_url\": \"{cert_url}\",
|
||||
\"transmission_id\": \"{transmission_id}\",
|
||||
\"transmission_sig\": \"{transmission_sig}\",
|
||||
\"transmission_time\": \"{transmission_time}\",
|
||||
\"webhook_id\": \"{}\",
|
||||
\"webhook_event\": {body}
|
||||
}}",
|
||||
dotenvy::var("PAYPAL_WEBHOOK_ID")?
|
||||
)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if &webhook_res.verification_status != "SUCCESS" {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid webhook signature".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalResource {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalWebhook {
|
||||
pub event_type: String,
|
||||
pub resource: PayPalResource,
|
||||
}
|
||||
|
||||
let webhook = serde_json::from_str::<PayPalWebhook>(&body)?;
|
||||
|
||||
match &*webhook.event_type {
|
||||
"PAYMENT.PAYOUTS-ITEM.BLOCKED"
|
||||
| "PAYMENT.PAYOUTS-ITEM.DENIED"
|
||||
| "PAYMENT.PAYOUTS-ITEM.REFUNDED"
|
||||
| "PAYMENT.PAYOUTS-ITEM.RETURNED"
|
||||
| "PAYMENT.PAYOUTS-ITEM.CANCELED" => {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2",
|
||||
webhook.resource.payout_item_id,
|
||||
PayoutStatus::InTransit.as_str()
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Some(result) = result {
|
||||
let mtx =
|
||||
payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64));
|
||||
let _guard = mtx.lock().await;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
result.amount + result.fee.unwrap_or(Decimal::ZERO),
|
||||
result.user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
crate::database::models::user_item::User::clear_caches(
|
||||
&[(crate::database::models::UserId(result.user_id), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts
|
||||
SET status = $1
|
||||
WHERE platform_id = $2
|
||||
",
|
||||
if &*webhook.event_type == "PAYMENT.PAYOUTS-ITEM.CANCELED" {
|
||||
PayoutStatus::Cancelled
|
||||
} else {
|
||||
PayoutStatus::Failed
|
||||
}
|
||||
.as_str(),
|
||||
webhook.resource.payout_item_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
}
|
||||
}
|
||||
"PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => {
|
||||
let mut transaction = pool.begin().await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts
|
||||
SET status = $1
|
||||
WHERE platform_id = $2
|
||||
",
|
||||
PayoutStatus::Success.as_str(),
|
||||
webhook.resource.payout_item_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("_tremendous")]
|
||||
pub async fn tremendous_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
payouts: web::Data<PayoutsQueue>,
|
||||
body: String,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let signature = req
|
||||
.headers()
|
||||
.get("Tremendous-Webhook-Signature")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.and_then(|x| x.split('=').next_back())
|
||||
.ok_or_else(|| ApiError::InvalidInput("missing webhook signature".to_string()))?;
|
||||
|
||||
let mut mac: Hmac<Sha256> =
|
||||
Hmac::new_from_slice(dotenvy::var("TREMENDOUS_PRIVATE_KEY")?.as_bytes())
|
||||
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
|
||||
mac.update(body.as_bytes());
|
||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
||||
|
||||
if &*request_signature != signature {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid webhook signature".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TremendousResource {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousPayload {
|
||||
pub resource: TremendousResource,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousWebhook {
|
||||
pub event: String,
|
||||
pub payload: TremendousPayload,
|
||||
}
|
||||
|
||||
let webhook = serde_json::from_str::<TremendousWebhook>(&body)?;
|
||||
|
||||
match &*webhook.event {
|
||||
"REWARDS.CANCELED" | "REWARDS.DELIVERY.FAILED" => {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2",
|
||||
webhook.payload.resource.id,
|
||||
PayoutStatus::InTransit.as_str()
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Some(result) = result {
|
||||
let mtx =
|
||||
payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64));
|
||||
let _guard = mtx.lock().await;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
result.amount + result.fee.unwrap_or(Decimal::ZERO),
|
||||
result.user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
crate::database::models::user_item::User::clear_caches(
|
||||
&[(crate::database::models::UserId(result.user_id), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts
|
||||
SET status = $1
|
||||
WHERE platform_id = $2
|
||||
",
|
||||
if &*webhook.event == "REWARDS.CANCELED" {
|
||||
PayoutStatus::Cancelled
|
||||
} else {
|
||||
PayoutStatus::Failed
|
||||
}
|
||||
.as_str(),
|
||||
webhook.payload.resource.id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
}
|
||||
}
|
||||
"REWARDS.DELIVERY.SUCCEEDED" => {
|
||||
let mut transaction = pool.begin().await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts
|
||||
SET status = $1
|
||||
WHERE platform_id = $2
|
||||
",
|
||||
PayoutStatus::Success.as_str(),
|
||||
webhook.payload.resource.id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[get("")]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_READ]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let payout_ids =
|
||||
crate::database::models::payout_item::Payout::get_all_for_user(user.id.into(), &**pool)
|
||||
.await?;
|
||||
let payouts =
|
||||
crate::database::models::payout_item::Payout::get_many(&payout_ids, &**pool).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(
|
||||
payouts
|
||||
.into_iter()
|
||||
.map(crate::models::payouts::Payout::from)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Withdrawal {
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
amount: Decimal,
|
||||
method: PayoutMethodType,
|
||||
method_id: String,
|
||||
}
|
||||
|
||||
#[post("")]
|
||||
pub async fn create_payout(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (scopes, user) =
|
||||
get_user_record_from_bearer_token(&req, None, &**pool, &redis, &session_queue)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidCredentials))?;
|
||||
|
||||
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
|
||||
return Err(ApiError::Authentication(
|
||||
AuthenticationError::InvalidCredentials,
|
||||
));
|
||||
}
|
||||
|
||||
let mtx = payouts_queue.lock_user_payouts(user.id.into());
|
||||
let _guard = mtx.lock().await;
|
||||
|
||||
if user.balance < body.amount || body.amount < Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let payout_method = payouts_queue
|
||||
.get_payout_methods()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|x| x.id == body.method_id)
|
||||
.ok_or_else(|| ApiError::InvalidInput("Invalid payment method specified!".to_string()))?;
|
||||
|
||||
let fee = std::cmp::min(
|
||||
std::cmp::max(
|
||||
payout_method.fee.min,
|
||||
payout_method.fee.percentage * body.amount,
|
||||
),
|
||||
payout_method.fee.max.unwrap_or(Decimal::MAX),
|
||||
);
|
||||
|
||||
let transfer = (body.amount - fee).round_dp(2);
|
||||
if transfer <= Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You need to withdraw more to cover the fee!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let payout_id = generate_payout_id(&mut transaction).await?;
|
||||
|
||||
let payout_item = match body.method {
|
||||
PayoutMethodType::Venmo | PayoutMethodType::PayPal => {
|
||||
let (wallet, wallet_type, address, display_address) =
|
||||
if body.method == PayoutMethodType::Venmo {
|
||||
if let Some(venmo) = user.venmo_handle {
|
||||
("Venmo", "user_handle", venmo.clone(), venmo)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Venmo address has not been set for account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else if let Some(paypal_id) = user.paypal_id {
|
||||
if let Some(paypal_country) = user.paypal_country {
|
||||
if &*paypal_country == "US" && &*body.method_id != "paypal_us" {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the US PayPal transfer option!".to_string(),
|
||||
));
|
||||
} else if &*paypal_country != "US" && &*body.method_id == "paypal_us" {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the International PayPal transfer option!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
(
|
||||
"PayPal",
|
||||
"paypal_id",
|
||||
paypal_id.clone(),
|
||||
user.paypal_email.unwrap_or(paypal_id),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please re-link your PayPal account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You have not linked a PayPal account!".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
let mut payout_item = crate::database::models::payout_item::Payout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: transfer,
|
||||
fee: Some(fee),
|
||||
method: Some(body.method),
|
||||
method_address: Some(display_address),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
let res: PayoutsResponse = payouts_queue.make_paypal_request(
|
||||
Method::POST,
|
||||
"payments/payouts",
|
||||
Some(
|
||||
json! ({
|
||||
"sender_batch_header": {
|
||||
"sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()),
|
||||
"email_subject": "You have received a payment from Modrinth!",
|
||||
"email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.",
|
||||
},
|
||||
"items": [{
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": transfer.to_string()
|
||||
},
|
||||
"receiver": address,
|
||||
"note": "Payment from Modrinth creator monetization program",
|
||||
"recipient_type": wallet_type,
|
||||
"recipient_wallet": wallet,
|
||||
"sender_item_id": crate::models::ids::PayoutId::from(payout_id),
|
||||
}]
|
||||
})
|
||||
),
|
||||
None,
|
||||
None
|
||||
).await?;
|
||||
|
||||
if let Some(link) = res.links.first() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutItem {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutItem>,
|
||||
}
|
||||
|
||||
if let Ok(res) = payouts_queue
|
||||
.make_paypal_request::<(), PayoutData>(
|
||||
Method::GET,
|
||||
&link.href,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some(data) = res.items.first() {
|
||||
payout_item.platform_id = Some(data.payout_item_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
payout_item
|
||||
}
|
||||
PayoutMethodType::Tremendous => {
|
||||
if let Some(email) = user.email {
|
||||
if user.email_verified {
|
||||
let mut payout_item = crate::database::models::payout_item::Payout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: transfer,
|
||||
fee: Some(fee),
|
||||
method: Some(PayoutMethodType::Tremendous),
|
||||
method_address: Some(email.clone()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reward {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Order {
|
||||
pub rewards: Vec<Reward>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousResponse {
|
||||
pub order: Order,
|
||||
}
|
||||
|
||||
let res: TremendousResponse = payouts_queue
|
||||
.make_tremendous_request(
|
||||
Method::POST,
|
||||
"orders",
|
||||
Some(json! ({
|
||||
"payment": {
|
||||
"funding_source_id": "BALANCE",
|
||||
},
|
||||
"rewards": [{
|
||||
"value": {
|
||||
"denomination": transfer
|
||||
},
|
||||
"delivery": {
|
||||
"method": "EMAIL"
|
||||
},
|
||||
"recipient": {
|
||||
"name": user.username,
|
||||
"email": email
|
||||
},
|
||||
"products": [
|
||||
&body.method_id,
|
||||
],
|
||||
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reward) = res.order.rewards.first() {
|
||||
payout_item.platform_id = Some(reward.id.clone())
|
||||
}
|
||||
|
||||
payout_item
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must verify your account email to proceed!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must add an email to your account to proceed!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::Payments(
|
||||
"Invalid payment method specified!".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance - $1
|
||||
WHERE id = $2
|
||||
",
|
||||
body.amount,
|
||||
user.id as crate::database::models::ids::UserId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
payout_item.insert(&mut transaction).await?;
|
||||
crate::database::models::User::clear_caches(&[(user.id, None)], &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
pub async fn cancel_payout(
|
||||
info: web::Path<(PayoutId,)>,
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
payouts: web::Data<PayoutsQueue>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let id = info.into_inner().0;
|
||||
let payout = crate::database::models::payout_item::Payout::get(id.into(), &**pool).await?;
|
||||
|
||||
if let Some(payout) = payout {
|
||||
if payout.user_id != user.id.into() && !user.role.is_admin() {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
}
|
||||
|
||||
if let Some(platform_id) = payout.platform_id {
|
||||
if let Some(method) = payout.method {
|
||||
if payout.status != PayoutStatus::InTransit {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match method {
|
||||
PayoutMethodType::Venmo | PayoutMethodType::PayPal => {
|
||||
payouts
|
||||
.make_paypal_request::<(), ()>(
|
||||
Method::POST,
|
||||
&format!("payments/payouts-item/{}/cancel", platform_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
PayoutMethodType::Tremendous => {
|
||||
payouts
|
||||
.make_tremendous_request::<(), ()>(
|
||||
Method::POST,
|
||||
&format!("rewards/{}/cancel", platform_id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts
|
||||
SET status = $1
|
||||
WHERE platform_id = $2
|
||||
",
|
||||
PayoutStatus::Cancelling.as_str(),
|
||||
platform_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().finish())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MethodFilter {
|
||||
pub country: Option<String>,
|
||||
}
|
||||
|
||||
#[get("methods")]
|
||||
pub async fn payment_methods(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
filter: web::Query<MethodFilter>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let methods = payouts_queue
|
||||
.get_payout_methods()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
let mut val = true;
|
||||
|
||||
if let Some(country) = &filter.country {
|
||||
val &= x.supported_countries.contains(country);
|
||||
}
|
||||
|
||||
val
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(methods))
|
||||
}
|
||||
@@ -378,6 +378,7 @@ pub struct NewTeamMember {
|
||||
#[serde(default)]
|
||||
pub organization_permissions: Option<OrganizationPermissions>,
|
||||
#[serde(default)]
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
pub payouts_split: Decimal,
|
||||
#[serde(default = "default_ordering")]
|
||||
pub ordering: i64,
|
||||
|
||||
@@ -3,11 +3,8 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::Mutex;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
@@ -20,9 +17,9 @@ use crate::{
|
||||
notifications::Notification,
|
||||
pats::Scopes,
|
||||
projects::Project,
|
||||
users::{Badges, Payout, PayoutStatus, RecipientStatus, Role, UserPayoutData},
|
||||
users::{Badges, Role},
|
||||
},
|
||||
queue::{payouts::PayoutsQueue, session::AuthQueue},
|
||||
queue::session::AuthQueue,
|
||||
util::{routes::read_from_payload, validate::validation_errors_to_string},
|
||||
};
|
||||
|
||||
@@ -43,9 +40,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.route("{id}", web::delete().to(user_delete))
|
||||
.route("{id}/follows", web::get().to(user_follows))
|
||||
.route("{id}/notifications", web::get().to(user_notifications))
|
||||
.route("{id}/payouts", web::get().to(user_payouts))
|
||||
.route("{id}/payouts_fees", web::get().to(user_payouts_fees))
|
||||
.route("{id}/payouts", web::post().to(user_payouts_request))
|
||||
.route("{id}/oauth_apps", web::get().to(get_user_clients)),
|
||||
);
|
||||
}
|
||||
@@ -302,6 +296,8 @@ pub struct EditUser {
|
||||
pub bio: Option<Option<String>>,
|
||||
pub role: Option<Role>,
|
||||
pub badges: Option<Badges>,
|
||||
#[validate(length(max = 160))]
|
||||
pub venmo_handle: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn user_edit(
|
||||
@@ -312,7 +308,7 @@ pub async fn user_edit(
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (_scopes, user) = get_user_from_headers(
|
||||
let (scopes, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -432,6 +428,27 @@ pub async fn user_edit(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(venmo_handle) = &new_user.venmo_handle {
|
||||
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have the permissions to edit the venmo handle of this user!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET venmo_handle = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
venmo_handle,
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -682,233 +699,3 @@ pub async fn user_notifications(
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_READ]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(id) = id_option.map(|x| x.id) {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to see the payouts of this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (all_time, last_month, payouts) = futures::future::try_join3(
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT SUM(pv.amount) amount
|
||||
FROM payouts_values pv
|
||||
WHERE pv.user_id = $1
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_one(&**pool),
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT SUM(pv.amount) amount
|
||||
FROM payouts_values pv
|
||||
WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_one(&**pool),
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT hp.created, hp.amount, hp.status
|
||||
FROM historical_payouts hp
|
||||
WHERE hp.user_id = $1
|
||||
ORDER BY hp.created DESC
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_many(&**pool)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|row| Payout {
|
||||
created: row.created,
|
||||
amount: row.amount,
|
||||
status: PayoutStatus::from_string(&row.status),
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Payout>>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!({
|
||||
"all_time": all_time.amount,
|
||||
"last_month": last_month.amount,
|
||||
"payouts": payouts,
|
||||
})))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeeEstimateAmount {
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
pub async fn user_payouts_fees(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
web::Query(amount): web::Query<FeeEstimateAmount>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_READ]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(actual_user) = actual_user {
|
||||
if !user.role.is_admin() && user.id != actual_user.id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to request payouts of this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(UserPayoutData {
|
||||
trolley_id: Some(trolley_id),
|
||||
..
|
||||
}) = user.payout_data
|
||||
{
|
||||
let payouts = payouts_queue
|
||||
.lock()
|
||||
.await
|
||||
.get_estimated_fees(&trolley_id, amount.amount)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(payouts))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You must set up your trolley account first!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PayoutData {
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
pub async fn user_payouts_request(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Json<PayoutData>,
|
||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let mut payouts_queue = payouts_queue.lock().await;
|
||||
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(id) = id_option.map(|x| x.id) {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to request payouts of this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(UserPayoutData {
|
||||
trolley_id: Some(trolley_id),
|
||||
trolley_status: Some(trolley_status),
|
||||
balance,
|
||||
..
|
||||
}) = user.payout_data
|
||||
{
|
||||
if trolley_status == RecipientStatus::Active {
|
||||
return if data.amount < balance {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let (batch_id, payment_id) =
|
||||
payouts_queue.send_payout(&trolley_id, data.amount).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
data.amount,
|
||||
"processing",
|
||||
batch_id,
|
||||
payment_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance - $1
|
||||
WHERE id = $2
|
||||
",
|
||||
data.amount,
|
||||
id as crate::database::models::ids::UserId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
User::clear_caches(&[(id, None)], &redis).await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
))
|
||||
};
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please complete payout information via the trolley dashboard!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ApiError::InvalidInput(
|
||||
"You are not enrolled in the payouts program yet!".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user