diff --git a/Cargo.toml b/Cargo.toml index 82b0bc99c..6bbc541a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,4 +66,4 @@ env_logger = "0.9.1" thiserror = "1.0.37" sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "chrono", "offline", "macros", "migrate", "decimal"] } -rust_decimal = { version = "1.26", features = ["serde-with-float"] } \ No newline at end of file +rust_decimal = { version = "1.26", features = ["serde-with-float", "serde-with-str"] } \ No newline at end of file diff --git a/migrations/20221111202802_fix-precision-again.sql b/migrations/20221111202802_fix-precision-again.sql new file mode 100644 index 000000000..aacf856af --- /dev/null +++ b/migrations/20221111202802_fix-precision-again.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE users ALTER balance TYPE numeric(40, 20); \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index d4b0133d5..493f7238f 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -31,6 +31,26 @@ }, "query": "\n INSERT INTO users (\n id, github_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n " }, + "03284fe5b045e2cf93f160863c4d121439382b348b728fffb5ac588dee980731": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)\n " + }, "041f499f542ddab1b81bd445d6cabe225b1b2ad3ec7bbc1f755346c016ae06e6": { "describe": { "columns": [], diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index af0b887a2..0ac919c48 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -1,5 +1,6 @@ use crate::routes::ApiError; use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; @@ -26,10 +27,11 @@ pub struct PayoutItem { pub sender_item_id: String, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct PayoutAmount { pub currency: String, - pub value: String, + #[serde(with = "rust_decimal::serde::str")] + pub value: Decimal, } // Batches payouts and handles token refresh @@ -85,8 +87,8 @@ impl PayoutsQueue { pub async fn send_payout( &mut self, - payout: PayoutItem, - ) -> Result<(), ApiError> { + mut payout: PayoutItem, + ) -> Result { if self.credential_expires < Utc::now() { self.refresh_token().await.map_err(|_| { ApiError::Payments( @@ -95,6 +97,22 @@ impl PayoutsQueue { })?; } + let fee = std::cmp::min( + std::cmp::max( + Decimal::ONE / Decimal::from(4), + (Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value, + ), + Decimal::from(20), + ); + + payout.amount.value -= fee; + + if payout.amount.value <= Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You do not have enough funds to make this payout!".to_string(), + )); + } + let client = reqwest::Client::new(); let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?)) @@ -130,8 +148,58 @@ impl PayoutsQueue { "Error while registering payment in PayPal: {}", body.body.message ))); + } else { + #[derive(Deserialize)] + struct PayPalLink { + href: String, + } + + #[derive(Deserialize)] + struct PayoutsResponse { + pub links: Vec, + } + + #[derive(Deserialize)] + struct PayoutDataItem { + payout_item_fee: PayoutAmount, + } + + #[derive(Deserialize)] + struct PayoutData { + pub items: Vec, + } + + // Calculate actual fee + refund if we took too big of a fee. + if let Some(res) = res.json::().await.ok() { + if let Some(link) = res.links.first() { + if let Some(res) = client + .get(&link.href) + .header( + "Authorization", + format!( + "{} {}", + self.credential.token_type, + self.credential.access_token + ), + ) + .send() + .await + .ok() + { + if let Some(res) = res.json::().await.ok() { + 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(()) + Ok(Decimal::ZERO) } } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 5e3ada204..8db7a4b56 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -264,7 +264,7 @@ pub async fn process_payout( let members_sum: Decimal = members.iter().map(|x| x.1).sum(); - if members_sum > Decimal::from(0) { + if members_sum > Decimal::ZERO { for member in members { let member_multiplier: Decimal = member.1 / members_sum; @@ -287,8 +287,8 @@ pub async fn process_payout( let project_multiplier: Decimal = Decimal::from(**value) / Decimal::from(multipliers.sum); - let default_split_given = Decimal::from(1); - let split_given = Decimal::from(1) / Decimal::from(5); + let default_split_given = Decimal::ONE; + let split_given = Decimal::ONE / Decimal::from(5); let split_retention = Decimal::from(4) / Decimal::from(5); let sum_splits: Decimal = @@ -296,7 +296,7 @@ pub async fn process_payout( let sum_tm_splits: Decimal = project.split_team_members.iter().map(|x| x.1).sum(); - if sum_splits > Decimal::from(0) { + if sum_splits > Decimal::ZERO { for (user_id, split) in project.team_members { let payout: Decimal = data.amount * project_multiplier @@ -307,7 +307,7 @@ pub async fn process_payout( &default_split_given }); - if payout > Decimal::from(0) { + if payout > Decimal::ZERO { sqlx::query!( " INSERT INTO payouts_values (user_id, mod_id, amount, created) @@ -336,14 +336,14 @@ pub async fn process_payout( } } - if sum_tm_splits > Decimal::from(0) { + if sum_tm_splits > Decimal::ZERO { for (user_id, split, project_id) in project.split_team_members { let payout: Decimal = data.amount * project_multiplier * (split / sum_tm_splits) * split_retention; - if payout > Decimal::from(0) { + if payout > Decimal::ZERO { sqlx::query!( " INSERT INTO payouts_values (user_id, mod_id, amount, created) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index d448ab8ff..28863747b 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -274,7 +274,7 @@ pub async fn auth_callback( created: Utc::now(), role: Role::Developer.to_string(), badges: Badges::default(), - balance: Decimal::from(0), + balance: Decimal::ZERO, payout_wallet: None, payout_wallet_type: None, payout_address: None, diff --git a/src/routes/project_creation.rs b/src/routes/project_creation.rs index 31ac02c19..5b177d22b 100644 --- a/src/routes/project_creation.rs +++ b/src/routes/project_creation.rs @@ -627,7 +627,7 @@ pub async fn project_create_inner( role: crate::models::teams::OWNER_ROLE.to_owned(), permissions: crate::models::teams::Permissions::ALL, accepted: true, - payouts_split: Decimal::from(100), + payouts_split: Decimal::ONE_HUNDRED, }], }; diff --git a/src/routes/teams.rs b/src/routes/teams.rs index c5da390e0..ad74c9235 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -260,7 +260,7 @@ pub async fn add_team_member( )); } - if new_member.payouts_split < Decimal::from(0) + if new_member.payouts_split < Decimal::ZERO || new_member.payouts_split > Decimal::from(5000) { return Err(ApiError::InvalidInput( @@ -425,8 +425,7 @@ pub async fn edit_team_member( } if let Some(payouts_split) = edit_member.payouts_split { - if payouts_split < Decimal::from(0) - || payouts_split > Decimal::from(5000) + if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000) { return Err(ApiError::InvalidInput( "Payouts split must be between 0 and 5000!".to_string(), diff --git a/src/routes/users.rs b/src/routes/users.rs index ffcbe974f..13462e589 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -345,6 +345,22 @@ pub async fn user_edit( )); } + 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 @@ -720,13 +736,25 @@ pub async fn user_payouts_request( payouts_data.payout_wallet_type { if let Some(payout_wallet) = payouts_data.payout_wallet { - let paypal_fee = Decimal::from(1) / Decimal::from(4); - - return if data.amount < payouts_data.balance - && data.amount > paypal_fee - { + return if data.amount < payouts_data.balance { let mut transaction = pool.begin().await?; + let mut payouts_queue = payouts_queue.lock().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()), + }) + .await?; + sqlx::query!( " INSERT INTO historical_payouts (user_id, amount, status) @@ -745,27 +773,12 @@ pub async fn user_payouts_request( SET balance = balance - $1 WHERE id = $2 ", - data.amount, + data.amount - leftover, id as crate::database::models::ids::UserId ) .execute(&mut *transaction) .await?; - let mut payouts_queue = payouts_queue.lock().await; - payouts_queue - .send_payout(PayoutItem { - amount: PayoutAmount { - currency: "USD".to_string(), - value: (data.amount - paypal_fee).to_string(), - }, - 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()), - }) - .await?; - transaction.commit().await?; Ok(HttpResponse::NoContent().body(""))