diff --git a/.env b/.env index 01e72f08..f142c3cd 100644 --- a/.env +++ b/.env @@ -49,9 +49,9 @@ WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "github.com", "raw.githubuserc ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]' -PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/ -PAYPAL_CLIENT_ID=none -PAYPAL_CLIENT_SECRET=none +TROLLEY_ACCESS_KEY=none +TROLLEY_SECRET_KEY=none +TROLLEY_WEBHOOK_SIGNATURE=none GITHUB_CLIENT_ID=none GITHUB_CLIENT_SECRET=none diff --git a/migrations/20230919183129_trolley.sql b/migrations/20230919183129_trolley.sql new file mode 100644 index 00000000..57b41b82 --- /dev/null +++ b/migrations/20230919183129_trolley.sql @@ -0,0 +1,13 @@ +ALTER TABLE users + ADD COLUMN trolley_id text NULL, + ADD COLUMN trolley_account_status text NULL, + DROP COLUMN midas_expires, + DROP COLUMN is_overdue, + DROP COLUMN stripe_customer_id, + DROP COLUMN payout_wallet, + DROP COLUMN payout_wallet_type, + DROP COLUMN payout_address; + +ALTER TABLE historical_payouts + ADD COLUMN batch_id text NULL, + ADD COLUMN payment_id text NULL; \ No newline at end of file diff --git a/src/auth/flows.rs b/src/auth/flows.rs index 8b13524b..86c1fa50 100644 --- a/src/auth/flows.rs +++ b/src/auth/flows.rs @@ -224,9 +224,6 @@ impl TempUser { role: Role::Developer.to_string(), badges: Badges::default(), balance: Decimal::ZERO, - payout_wallet: None, - payout_wallet_type: None, - payout_address: None, } .insert(transaction) .await?; @@ -1384,9 +1381,6 @@ pub async fn create_account_with_password( role: Role::Developer.to_string(), badges: Badges::default(), balance: Decimal::ZERO, - payout_wallet: None, - payout_wallet_type: None, - payout_address: None, } .insert(&mut transaction) .await?; diff --git a/src/auth/validate.rs b/src/auth/validate.rs index 601e97d7..dfa1854c 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -3,7 +3,7 @@ use crate::auth::session::get_session_metadata; use crate::auth::AuthenticationError; use crate::database::models::user_item; use crate::models::pats::Scopes; -use crate::models::users::{Role, User, UserId, UserPayoutData}; +use crate::models::users::{Role, User, UserId}; use crate::queue::session::AuthQueue; use actix_web::HttpRequest; use chrono::Utc; @@ -56,16 +56,11 @@ where created: db_user.created, role: Role::from_string(&db_user.role), badges: db_user.badges, - payout_data: Some(UserPayoutData { - balance: db_user.balance, - payout_wallet: db_user.payout_wallet, - payout_wallet_type: db_user.payout_wallet_type, - payout_address: db_user.payout_address, - }), auth_providers: Some(auth_providers), has_password: Some(db_user.password.is_some()), has_totp: Some(db_user.totp_secret.is_some()), github_id: None, + payout_data: None, }; if let Some(required_scopes) = required_scopes { diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index f93a137f..9e5846a7 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -1,7 +1,7 @@ use super::ids::{ProjectId, UserId}; use crate::database::models::DatabaseError; use crate::models::ids::base62_impl::{parse_base62, to_base62}; -use crate::models::users::{Badges, RecipientType, RecipientWallet}; +use crate::models::users::Badges; use chrono::{DateTime, Utc}; use redis::cmd; use rust_decimal::Decimal; @@ -36,9 +36,6 @@ pub struct User { pub role: String, pub badges: Badges, pub balance: Decimal, - pub payout_wallet: Option, - pub payout_wallet_type: Option, - pub payout_address: Option, } impl User { @@ -206,7 +203,7 @@ impl User { SELECT id, name, email, avatar_url, username, bio, created, role, badges, - balance, payout_wallet, payout_wallet_type, payout_address, + balance, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, totp_secret FROM users @@ -238,11 +235,6 @@ impl User { role: u.role, badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), balance: u.balance, - payout_wallet: u.payout_wallet.map(|x| RecipientWallet::from_string(&x)), - payout_wallet_type: u - .payout_wallet_type - .map(|x| RecipientType::from_string(&x)), - payout_address: u.payout_address, password: u.password, totp_secret: u.totp_secret, })) diff --git a/src/main.rs b/src/main.rs index 427311b4..9299be23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -503,9 +503,9 @@ fn check_env_vars() -> bool { failed |= true; } - failed |= check_var::("PAYPAL_API_URL"); - failed |= check_var::("PAYPAL_CLIENT_ID"); - failed |= check_var::("PAYPAL_CLIENT_SECRET"); + failed |= check_var::("TROLLEY_ACCESS_KEY"); + failed |= check_var::("TROLLEY_SECRET_KEY"); + failed |= check_var::("TROLLEY_WEBHOOK_SIGNATURE"); failed |= check_var::("GITHUB_CLIENT_ID"); failed |= check_var::("GITHUB_CLIENT_SECRET"); diff --git a/src/models/users.rs b/src/models/users.rs index 7b1a2a98..5cdd7232 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -46,12 +46,12 @@ pub struct User { pub role: Role, pub badges: Badges, - pub payout_data: Option, pub auth_providers: Option>, pub email: Option, pub email_verified: Option, pub has_password: Option, pub has_totp: Option, + pub payout_data: Option, // DEPRECATED. Always returns None pub github_id: Option, @@ -60,77 +60,8 @@ pub struct User { #[derive(Serialize, Deserialize, Clone)] pub struct UserPayoutData { pub balance: Decimal, - pub payout_wallet: Option, - pub payout_wallet_type: Option, - pub payout_address: Option, -} - -#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] -#[serde(rename_all = "snake_case")] -pub enum RecipientType { - Email, - Phone, - UserHandle, -} - -impl std::fmt::Display for RecipientType { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - fmt.write_str(self.as_str()) - } -} - -impl RecipientType { - pub fn from_string(string: &str) -> RecipientType { - match string { - "user_handle" => RecipientType::UserHandle, - "phone" => RecipientType::Phone, - _ => RecipientType::Email, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - RecipientType::Email => "email", - RecipientType::Phone => "phone", - RecipientType::UserHandle => "user_handle", - } - } -} - -#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] -#[serde(rename_all = "snake_case")] -pub enum RecipientWallet { - Venmo, - Paypal, -} - -impl std::fmt::Display for RecipientWallet { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - fmt.write_str(self.as_str()) - } -} - -impl RecipientWallet { - pub fn from_string(string: &str) -> RecipientWallet { - match string { - "venmo" => RecipientWallet::Venmo, - _ => RecipientWallet::Paypal, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - RecipientWallet::Paypal => "paypal", - RecipientWallet::Venmo => "venmo", - } - } - - pub fn as_str_api(&self) -> &'static str { - match self { - RecipientWallet::Paypal => "PayPal", - RecipientWallet::Venmo => "Venmo", - } - } + pub trolley_id: Option, + pub trolley_status: Option, } use crate::database::models::user_item::User as DBUser; diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index 57df7054..caf48245 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -1,203 +1,231 @@ use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; use crate::util::env::parse_var; +use actix::Recipient; use base64::Engine; use chrono::{DateTime, Datelike, Duration, Utc, Weekday}; +use hex::ToHex; +use hmac::{Hmac, Mac, NewMac}; +use reqwest::Method; use rust_decimal::Decimal; +use s3::creds::time::macros::time; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; +use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; pub struct PayoutsQueue { - credential: PaypalCredential, - credential_expires: DateTime, + access_key: String, + secret_key: String, } -#[derive(Deserialize, Default)] -struct PaypalCredential { - access_token: String, - token_type: String, - expires_in: i64, -} - -#[derive(Serialize)] -pub struct PayoutItem { - pub amount: PayoutAmount, - pub receiver: String, - pub note: String, - pub recipient_type: String, - pub recipient_wallet: String, - pub sender_item_id: String, -} - -#[derive(Serialize, Deserialize)] -pub struct PayoutAmount { - pub currency: String, - #[serde(with = "rust_decimal::serde::str")] - pub value: Decimal, +pub enum AccountUser { + Business { name: String }, + Individual { first: String, last: String }, } // Batches payouts and handles token refresh impl PayoutsQueue { pub fn new() -> Self { PayoutsQueue { - credential: Default::default(), - credential_expires: Utc::now() - Duration::days(30), + access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"), + secret_key: dotenvy::var("TROLLEY_SECRET_KEY").expect("missing trolley secret key"), } } - pub async fn refresh_token(&mut self) -> Result<(), ApiError> { + pub async fn make_trolley_request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let timestamp = Utc::now().timestamp(); + + let mut mac: Hmac = Hmac::new_from_slice(self.secret_key.as_bytes()) + .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; + mac.update( + if let Some(body) = &body { + format!( + "{}\n{}\n{}\n{}\n", + timestamp, + method.as_str(), + path, + serde_json::to_string(&body)? + ) + } else { + format!("{}\n{}\n{}\n\n", timestamp, method.as_str(), path) + } + .as_bytes(), + ); + let request_signature = mac.finalize().into_bytes().encode_hex::(); + let client = reqwest::Client::new(); - let combined_key = format!( - "{}:{}", - dotenvy::var("PAYPAL_CLIENT_ID")?, - dotenvy::var("PAYPAL_CLIENT_SECRET")? - ); - let formatted_key = format!( - "Basic {}", - base64::engine::general_purpose::STANDARD.encode(combined_key) - ); + let mut request = client + .request(method, format!("https://api.trolley.com{path}")) + .header( + "Authorization", + format!("prsign {}:{}", self.access_key, request_signature), + ) + .header("X-PR-Timestamp", timestamp); - let mut form = HashMap::new(); - form.insert("grant_type", "client_credentials"); + if let Some(body) = body { + request = request.json(&body); + } - let credential: PaypalCredential = client - .post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) - .header("Accept", "application/json") - .header("Accept-Language", "en_US") - .header("Authorization", formatted_key) - .form(&form) + let resp = request .send() .await - .map_err(|_| ApiError::Payments("Error while authenticating with PayPal".to_string()))? - .json() - .await - .map_err(|_| { - ApiError::Payments( - "Error while authenticating with PayPal (deser error)".to_string(), - ) - })?; + .map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?; + + let value = resp.json::().await.map_err(|_| { + ApiError::Payments("could not retrieve Trolley response body".to_string()) + })?; + + if let Some(obj) = value.as_object() { + if !obj.get("ok").map(|x| x.as_bool()).flatten().unwrap_or(true) { + #[derive(Deserialize)] + struct TrolleyError { + field: Option, + message: String, + } - self.credential_expires = Utc::now() + Duration::seconds(credential.expires_in); - self.credential = credential; + if let Some(array) = obj.get("errors") { + let err = serde_json::from_value::>(array.clone()).map_err( + |_| { + ApiError::Payments( + "could not retrieve Trolley error json body".to_string(), + ) + }, + )?; + + if let Some(first) = err.into_iter().next() { + return Err(ApiError::Payments(if let Some(field) = &first.field { + format!("error - field: {field} message: {}", first.message) + } else { + first.message + })); + } + } + } - Ok(()) + return Err(ApiError::Payments( + "could not retrieve Trolley error body".to_string(), + )); + } + + Ok(serde_json::from_value(value)?) } - pub async fn send_payout(&mut self, mut payout: PayoutItem) -> Result { - if self.credential_expires < Utc::now() { - self.refresh_token().await.map_err(|_| { - ApiError::Payments("Error while authenticating with PayPal".to_string()) - })?; + pub async fn send_payout( + &mut self, + recipient: &str, + amount: Decimal, + ) -> Result<(String, Option), ApiError> { + #[derive(Deserialize)] + struct TrolleyReq { + batch: Batch, } - let wallet = payout.recipient_wallet.clone(); - - let fee = if wallet == *"Venmo" { - Decimal::ONE / Decimal::from(4) - } else { - std::cmp::min( - std::cmp::max( - Decimal::ONE / Decimal::from(4), - (Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value, - ), - Decimal::from(20), - ) - }; + #[derive(Deserialize)] + struct Batch { + id: String, + payments: BatchPayments, + } - payout.amount.value -= fee; - payout.amount.value = payout.amount.value.round_dp(2); + #[derive(Deserialize)] + struct Payment { + id: String, + } - if payout.amount.value <= Decimal::ZERO { - return Err(ApiError::InvalidInput( - "You do not have enough funds to make this payout!".to_string(), - )); + #[derive(Deserialize)] + struct BatchPayments { + payments: Vec, } - let client = reqwest::Client::new(); + let res = self + .make_trolley_request::<_, TrolleyReq>( + Method::POST, + "/v1/batches/", + Some(json!({ + "currency": "USD", + "description": "labrinth payout", + "payments": [{ + "recipient": { + "id": recipient + }, + "amount": amount.to_string(), + "currency": "USD", + "memo": "Modrinth ad revenue payout" + }], + })), + ) + .await?; - let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?)) - .header("Authorization", format!("{} {}", self.credential.token_type, self.credential.access_token)) - .json(&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": vec![payout] - })) - .send().await.map_err(|_| ApiError::Payments("Error while sending payout to PayPal".to_string()))?; - - if !res.status().is_success() { - #[derive(Deserialize)] - struct PayPalError { - pub body: PayPalErrorBody, - } + self.make_trolley_request::( + Method::POST, + &format!("/v1/batches/{}/start-processing", res.batch.id), + None, + ) + .await?; - #[derive(Deserialize)] - struct PayPalErrorBody { - pub message: String, - } + let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id); - let body: PayPalError = res.json().await.map_err(|_| { - ApiError::Payments("Error while registering payment in PayPal!".to_string()) - })?; - - return Err(ApiError::Payments(format!( - "Error while registering payment in PayPal: {}", - body.body.message - ))); - } else if wallet != *"Venmo" { - #[derive(Deserialize)] - struct PayPalLink { - href: String, - } + Ok((res.batch.id, payment_id)) + } - #[derive(Deserialize)] - struct PayoutsResponse { - pub links: Vec, - } + // listen to webhooks for batch status, account status + pub async fn register_recipient( + &self, + email: &str, + user: AccountUser, + ) -> Result { + #[derive(Deserialize)] + struct TrolleyReq { + recipient: Recipient, + } - #[derive(Deserialize)] - struct PayoutDataItem { - payout_item_fee: PayoutAmount, - } + #[derive(Deserialize)] + struct Recipient { + id: String, + } - #[derive(Deserialize)] - struct PayoutData { - pub items: Vec, - } + let id = self + .make_trolley_request::<_, TrolleyReq>( + Method::POST, + "/v1/recipients/", + Some(match user { + AccountUser::Business { name } => json!({ + "type": "business", + "email": email, + "name": name, + }), + AccountUser::Individual { first, last } => json!({ + "type": "individual", + "firstName": first, + "lastName": last, + "email": email, + }), + }), + ) + .await?; - // Calculate actual fee + refund if we took too big of a fee. - if let Ok(res) = res.json::().await { - if let Some(link) = res.links.first() { - if let Ok(res) = client - .get(&link.href) - .header( - "Authorization", - format!( - "{} {}", - self.credential.token_type, self.credential.access_token - ), - ) - .send() - .await - { - if let Ok(res) = res.json::().await { - if let Some(data) = res.items.first() { - if (fee - data.payout_item_fee.value) > Decimal::ZERO { - return Ok(fee - data.payout_item_fee.value); - } - } - } - } - } - } - } + Ok(id.recipient.id) + } + + pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> { + self.make_trolley_request::<_, Value>( + Method::PATCH, + &format!("/v1/recipients/{}", id), + Some(json!({ + "email": email, + })), + ) + .await?; - Ok(Decimal::ZERO) + Ok(()) } } diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index 4b0f193f..ffb5156e 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -7,10 +7,14 @@ use crate::queue::maxmind::MaxMindIndexer; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::guards::admin_key_guard; +use crate::util::routes::read_from_payload; use crate::DownloadQueue; -use actix_web::{patch, web, HttpRequest, HttpResponse}; +use actix_web::{patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; +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; @@ -18,7 +22,11 @@ use std::sync::Arc; use uuid::Uuid; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("admin").service(count_download)); + cfg.service( + web::scope("admin") + .service(count_download) + .service(trolley_webhook), + ); } #[derive(Deserialize)] @@ -144,3 +152,65 @@ pub async fn count_download( Ok(HttpResponse::NoContent().body("")) } + +#[derive(Deserialize)] +pub struct TrolleyWebhook { + model: String, + action: String, + body: HashMap, +} + +#[post("/_trolley")] +#[allow(clippy::too_many_arguments)] +pub async fn trolley_webhook( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + mut payload: web::Payload, +) -> Result { + if let Some(signature) = req.headers().get("X-PaymentRails-Signature") { + let payload = read_from_payload( + &mut payload, + 1 * (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('=').skip(1).next()) + .unwrap_or_default(); + let v1 = signature + .next() + .and_then(|x| x.split('=').skip(1).next()) + .unwrap_or_default(); + + let mut mac: Hmac = + 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::(); + + if &*request_signature == v1 { + let webhook = serde_json::from_slice::(&payload)?; + + if webhook.model == "recipient" { + // todo: update email + recipient status + } + + if webhook.model == "payment" { + // todo: update payment status + // if new payment status is failed/returned, return money to modrinth balance + } + + println!( + "webhook: {} {} {:?}", + webhook.action, webhook.model, webhook.body + ); + } + } + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index 78645044..1fb3e60f 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -26,7 +26,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(crate::auth::pats::config) .configure(moderation::config) .configure(notifications::config) - //.configure(pats::config) .configure(project_creation::config) .configure(projects::config) .configure(reports::config) diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index ef590adb..c025a88b 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -4,8 +4,8 @@ use crate::file_hosting::FileHost; use crate::models::notifications::Notification; use crate::models::pats::Scopes; use crate::models::projects::Project; -use crate::models::users::{Badges, RecipientType, RecipientWallet, Role, UserId}; -use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue}; +use crate::models::users::{Badges, Role, UserId}; +use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::routes::read_from_payload; @@ -181,21 +181,6 @@ pub struct EditUser { pub bio: Option>, pub role: Option, pub badges: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - with = "::serde_with::rust::double_option" - )] - #[validate] - pub payout_data: Option>, -} - -#[derive(Serialize, Deserialize, Validate)] -pub struct EditPayoutData { - pub payout_wallet: RecipientWallet, - pub payout_wallet_type: RecipientType, - #[validate(length(max = 128))] - pub payout_address: String, } #[patch("{id}")] @@ -327,79 +312,6 @@ pub async fn user_edit( .await?; } - if let Some(payout_data) = &new_user.payout_data { - if let Some(payout_data) = payout_data { - if payout_data.payout_wallet_type == RecipientType::UserHandle - && payout_data.payout_wallet == RecipientWallet::Paypal - { - return Err(ApiError::InvalidInput( - "You cannot use a paypal wallet with a user handle!".to_string(), - )); - } - - if !scopes.contains(Scopes::PAYOUTS_WRITE) { - return Err(ApiError::Authentication( - AuthenticationError::InvalidCredentials, - )); - } - - if !match payout_data.payout_wallet_type { - RecipientType::Email => { - validator::validate_email(&payout_data.payout_address) - } - RecipientType::Phone => { - validator::validate_phone(&payout_data.payout_address) - } - RecipientType::UserHandle => true, - } { - return Err(ApiError::InvalidInput( - "Invalid wallet specified!".to_string(), - )); - } - - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL) - ", - id as crate::database::models::ids::UserId, - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput( - "You must have an email set on your Modrinth account to enroll in the monetization program!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE users - SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3 - WHERE (id = $4) - ", - payout_data.payout_wallet.as_str(), - payout_data.payout_wallet_type.as_str(), - payout_data.payout_address, - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } else { - sqlx::query!( - " - UPDATE users - SET payout_wallet = NULL, payout_wallet_type = NULL, payout_address = NULL - WHERE (id = $1) - ", - 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("")) @@ -775,54 +687,30 @@ pub async fn user_payouts_request( } if let Some(payouts_data) = user.payout_data { - if let Some(payout_address) = payouts_data.payout_address { - if let Some(payout_wallet_type) = payouts_data.payout_wallet_type { - if let Some(payout_wallet) = payouts_data.payout_wallet { + if let Some(trolley_id) = payouts_data.trolley_id { + if let Some(trolley_status) = payouts_data.trolley_status { + if trolley_status == "active" { return if data.amount < payouts_data.balance { let mut transaction = pool.begin().await?; - let leftover = payouts_queue - .send_payout(PayoutItem { - amount: PayoutAmount { - currency: "USD".to_string(), - value: data.amount, - }, - receiver: payout_address, - note: "Payment from Modrinth creator monetization program" - .to_string(), - recipient_type: payout_wallet_type.to_string().to_uppercase(), - recipient_wallet: payout_wallet.as_str_api().to_string(), - sender_item_id: format!( - "{}-{}", - UserId::from(id), - Utc::now().timestamp() - ), - }) + let (batch_id, payment_id) = payouts_queue + .send_payout(&trolley_id, data.amount) .await?; sqlx::query!( - " - INSERT INTO historical_payouts (user_id, amount, status) - VALUES ($1, $2, $3) - ", - id as crate::database::models::ids::UserId, - data.amount, - "success" - ) - .execute(&mut *transaction) - .await?; + " + 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 - leftover, - id as crate::database::models::ids::UserId - ) - .execute(&mut *transaction) - .await?; User::clear_caches(&[(id, None)], &redis).await?; transaction.commit().await?; @@ -833,6 +721,10 @@ pub async fn user_payouts_request( "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(), + )) } } }