From 4f4d7f9eae20aa5df5181f8669cfdb3e54e9717d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 30 Dec 2025 21:56:15 +0000 Subject: [PATCH 1/6] wip: payouts flow api --- apps/labrinth/src/models/v3/payouts.rs | 9 + apps/labrinth/src/queue/payouts/flow/mod.rs | 103 +++++++ apps/labrinth/src/queue/payouts/flow/mural.rs | 274 ++++++++++++++++++ .../labrinth/src/queue/payouts/flow/paypal.rs | 228 +++++++++++++++ .../src/queue/payouts/flow/tremendous.rs | 40 +++ apps/labrinth/src/queue/payouts/mod.rs | 15 +- apps/labrinth/src/queue/payouts/mural.rs | 81 ++++-- apps/labrinth/src/routes/v3/payouts.rs | 43 ++- 8 files changed, 744 insertions(+), 49 deletions(-) create mode 100644 apps/labrinth/src/queue/payouts/flow/mod.rs create mode 100644 apps/labrinth/src/queue/payouts/flow/mural.rs create mode 100644 apps/labrinth/src/queue/payouts/flow/paypal.rs create mode 100644 apps/labrinth/src/queue/payouts/flow/tremendous.rs diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs index c431ca7518..d83de44139 100644 --- a/apps/labrinth/src/models/v3/payouts.rs +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -3,6 +3,7 @@ use std::{cmp, collections::HashMap, fmt}; use crate::{models::ids::PayoutId, queue::payouts::mural::MuralPayoutRequest}; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; +use modrinth_util::decimal::Decimal2dp; use rust_decimal::Decimal; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -49,6 +50,14 @@ impl Payout { } } +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Withdrawal { + pub amount: Decimal, + #[serde(flatten)] + pub method: PayoutMethodRequest, + pub method_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "method", rename_all = "lowercase")] #[expect( diff --git a/apps/labrinth/src/queue/payouts/flow/mod.rs b/apps/labrinth/src/queue/payouts/flow/mod.rs new file mode 100644 index 0000000000..ac58c8b43d --- /dev/null +++ b/apps/labrinth/src/queue/payouts/flow/mod.rs @@ -0,0 +1,103 @@ +use eyre::eyre; +use rust_decimal::Decimal; +use sqlx::{PgPool, PgTransaction}; + +pub mod mural; +pub mod paypal; +pub mod tremendous; + +use crate::{ + database::models::{DBPayoutId, DBUser}, + models::payouts::{PayoutMethodRequest, Withdrawal}, + queue::payouts::PayoutsQueue, + routes::ApiError, + util::{error::Context, gotenberg::GotenbergClient}, +}; + +impl PayoutsQueue { + pub async fn create_payout_flow( + &self, + withdrawal: Withdrawal, + ) -> Result { + match withdrawal.method { + PayoutMethodRequest::PayPal => { + paypal::create(self, withdrawal.amount, false).await + } + PayoutMethodRequest::Venmo => { + paypal::create(self, withdrawal.amount, false).await + } + PayoutMethodRequest::MuralPay { method_details } => { + mural::create(self, withdrawal.amount, method_details).await + } + PayoutMethodRequest::Tremendous { method_details } => { + tremendous::create(self, withdrawal.amount, method_details) + .await + } + } + } +} + +#[derive(Debug)] +pub struct PayoutFlow { + pub total_fee: Decimal, + pub forex_usd_to_currency: Option, + inner: PayoutFlowInner, +} + +#[derive(Debug)] +enum PayoutFlowInner { + PayPal(paypal::PayPalFlow), + Mural(mural::MuralFlow), + Tremendous(tremendous::TremendousFlow), +} + +struct ExecuteContext<'a> { + queue: &'a PayoutsQueue, + user: &'a DBUser, + db: &'a PgPool, + payout_id: DBPayoutId, + transaction: PgTransaction<'a>, + gotenberg: &'a GotenbergClient, +} + +impl PayoutFlow { + pub async fn execute( + self, + queue: &PayoutsQueue, + user: &DBUser, + db: &PgPool, + payout_id: DBPayoutId, + transaction: PgTransaction<'_>, + gotenberg: &GotenbergClient, + ) -> Result<(), ApiError> { + let cx = ExecuteContext { + queue, + user, + db, + payout_id, + transaction, + gotenberg, + }; + + match self.inner { + PayoutFlowInner::PayPal(flow) => paypal::execute(cx, flow).await, + PayoutFlowInner::Mural(flow) => mural::execute(cx, flow).await, + PayoutFlowInner::Tremendous(flow) => { + tremendous::execute(cx, flow).await + } + } + } +} + +fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> { + let email = user.email.as_ref().wrap_request_err( + "you must add an email to your account to withdraw", + )?; + if !user.email_verified { + return Err(ApiError::Request(eyre!( + "you must verify your email to withdraw" + ))); + } + + Ok(email) +} diff --git a/apps/labrinth/src/queue/payouts/flow/mural.rs b/apps/labrinth/src/queue/payouts/flow/mural.rs new file mode 100644 index 0000000000..7d15f40c40 --- /dev/null +++ b/apps/labrinth/src/queue/payouts/flow/mural.rs @@ -0,0 +1,274 @@ +use ariadne::ids::UserId; +use chrono::Utc; +use eyre::eyre; +use modrinth_util::decimal::Decimal2dp; +use rust_decimal::{Decimal, RoundingStrategy, dec}; +use tracing::error; + +use crate::{ + database::models::payout_item::DBPayout, + models::payouts::{MuralPayDetails, PayoutMethodType, PayoutStatus}, + queue::payouts::{ + PayoutsQueue, + flow::{ + ExecuteContext, PayoutFlow, PayoutFlowInner, get_verified_email, + }, + mural::MuralPayoutRequest, + }, + routes::ApiError, + util::error::Context, +}; + +pub const OUR_FEE_PERCENT: Decimal = dec!(0.01); + +#[derive(Debug)] +pub(super) struct MuralFlow { + net_usd: Decimal2dp, + method_fee_usd: Decimal2dp, + platform_fee_usd: Decimal2dp, + payout_details: MuralPayoutRequest, + recipient_info: muralpay::CreatePayoutRecipientInfo, +} + +pub(super) async fn create( + queue: &PayoutsQueue, + amount: Decimal, + details: MuralPayDetails, +) -> Result { + let gross_usd = + Decimal2dp::new(amount).wrap_request_err("invalid amount")?; + let platform_fee_usd = + gross_usd.mul_round(OUR_FEE_PERCENT, RoundingStrategy::AwayFromZero); + + let mural = queue.muralpay.load(); + let mural = mural + .as_ref() + .wrap_internal_err("Mural client not available")?; + + let (method_fee_usd, forex_usd_to_currency) = match &details.payout_details + { + MuralPayoutRequest::Blockchain { .. } => (Decimal2dp::ZERO, None), + MuralPayoutRequest::Fiat { + fiat_and_rail_details, + .. + } => { + let fiat_and_rail_code = fiat_and_rail_details.code(); + + let fees = mural + .client + .get_fees_for_token_amount(&[muralpay::TokenFeeRequest { + amount: muralpay::TokenAmount { + token_symbol: muralpay::USDC.into(), + token_amount: gross_usd.get(), + }, + fiat_and_rail_code, + }]) + .await + .wrap_internal_err("failed to request fees")?; + let fee = fees + .into_iter() + .next() + .wrap_internal_err("no fees returned")?; + + match fee { + muralpay::TokenPayoutFee::Success { + exchange_rate, + fee_total, + .. + } => ( + Decimal2dp::rounded( + fee_total.token_amount, + RoundingStrategy::AwayFromZero, + ), + Some(exchange_rate), + ), + muralpay::TokenPayoutFee::Error { message, .. } => { + return Err(ApiError::Internal(eyre!( + "failed to compute fee: {message}" + ))); + } + } + } + }; + + let total_fee_usd = method_fee_usd + platform_fee_usd; + let net_usd = gross_usd - total_fee_usd; + + Ok(PayoutFlow { + total_fee: total_fee_usd.get(), + forex_usd_to_currency, + inner: PayoutFlowInner::Mural(MuralFlow { + net_usd, + method_fee_usd, + platform_fee_usd, + payout_details: details.payout_details, + recipient_info: details.recipient_info, + }), + }) +} + +pub(super) async fn execute( + ExecuteContext { + queue, + user, + payout_id, + db: _, + mut transaction, + gotenberg, + }: ExecuteContext<'_>, + MuralFlow { + net_usd, + method_fee_usd, + platform_fee_usd, + payout_details, + recipient_info, + }: MuralFlow, +) -> Result<(), ApiError> { + let user_email = get_verified_email(user)?; + let sent_to_method_usd = net_usd + method_fee_usd; + let total_fee_usd = method_fee_usd + platform_fee_usd; + + let mural = queue.muralpay.load(); + let mural = mural + .as_ref() + .wrap_internal_err("Mural client not available")?; + + let payment_statement_doc = queue + .create_mural_payment_statement_doc( + payout_id, + net_usd, + total_fee_usd, + &recipient_info, + gotenberg, + ) + .await?; + + let user_id = UserId::from(user.id); + let method_id = match &payout_details { + MuralPayoutRequest::Blockchain { .. } => { + "blockchain-usdc-polygon".to_string() + } + MuralPayoutRequest::Fiat { + fiat_and_rail_details, + .. + } => fiat_and_rail_details.code().to_string(), + }; + + let payout_details = match payout_details { + crate::queue::payouts::mural::MuralPayoutRequest::Fiat { + bank_name, + bank_account_owner, + fiat_and_rail_details, + } => muralpay::CreatePayoutDetails::Fiat { + bank_name, + bank_account_owner, + developer_fee: None, + fiat_and_rail_details, + }, + crate::queue::payouts::mural::MuralPayoutRequest::Blockchain { + wallet_address, + } => { + muralpay::CreatePayoutDetails::Blockchain { + wallet_details: muralpay::WalletDetails { + // only Polygon chain is currently supported + blockchain: muralpay::Blockchain::Polygon, + wallet_address, + }, + } + } + }; + + let payout = muralpay::CreatePayout { + amount: muralpay::TokenAmount { + token_amount: sent_to_method_usd.get(), + token_symbol: muralpay::USDC.into(), + }, + payout_details, + recipient_info, + supporting_details: Some(muralpay::SupportingDetails { + supporting_document: Some(format!( + "data:application/pdf;base64,{}", + payment_statement_doc.body + )), + payout_purpose: Some(muralpay::PayoutPurpose::VendorPayment), + }), + }; + + let payout_request = mural + .client + .create_payout_request( + mural.source_account_id, + Some(format!("User {user_id}")), + &[payout], + ) + .await + .map_err(|err| match err { + muralpay::MuralError::Api(err) => ApiError::Mural(Box::new(err)), + err => ApiError::Internal( + eyre!(err).wrap_err("failed to create payout request"), + ), + })?; + + // Once the Mural payout request has been created successfully, + // then we *must* commit *a* payout row into the DB, to link the Mural + // payout request to the `payout` row, and to subtract the user's balance. + // Even if we can't execute the payout afterwards. + // For this, we create a payout, try to execute it, and no matter what + // happens, insert the payout row. + // Otherwise if we don't put it into the DB, we've got a ghost Mural + // payout with no related database entry. + // However, this doesn't mean that the payout will definitely go through. + // For this, we need to execute it, and handle errors. + + let mut payout = DBPayout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + // after the payout has been successfully executed, + // we wait for Mural's confirmation that the funds have been delivered + // done in `SyncPayoutStatuses` background task + status: PayoutStatus::InTransit, + amount: net_usd.get(), + fee: Some(total_fee_usd.get()), + method: Some(PayoutMethodType::MuralPay), + method_id: Some(method_id), + method_address: Some(user_email.to_string()), + platform_id: Some(payout_request.id.to_string()), + }; + + // poor man's async try/catch block + let result = (async { + queue + .execute_mural_payout_request(payout_request.id) + .await + .wrap_internal_err("failed to execute payout request")?; + Ok::<_, ApiError>(()) + }) + .await; + + if let Err(caught_err) = result { + payout.status = PayoutStatus::Failed; + + // if execution fails, make sure to immediately cancel the payout request + // we don't want floating payout requests + if let Err(err) = + queue.cancel_mural_payout_request(payout_request.id).await + { + error!( + "Failed to cancel unexecuted payout request: {err:#}\noriginal error: {caught_err:#}" + ); + } + } + + payout + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(()) +} diff --git a/apps/labrinth/src/queue/payouts/flow/paypal.rs b/apps/labrinth/src/queue/payouts/flow/paypal.rs new file mode 100644 index 0000000000..aad6436b24 --- /dev/null +++ b/apps/labrinth/src/queue/payouts/flow/paypal.rs @@ -0,0 +1,228 @@ +use chrono::Utc; +use modrinth_util::decimal::Decimal2dp; +use reqwest::Method; +use rust_decimal::{Decimal, RoundingStrategy, dec}; +use serde::Deserialize; +use serde_json::json; +use tracing::error; + +use crate::{ + database::models::payout_item::DBPayout, + models::payouts::{PayoutMethodFee, PayoutMethodType, PayoutStatus}, + queue::payouts::{ + PayoutsQueue, + flow::{ExecuteContext, PayoutFlow, PayoutFlowInner}, + }, + routes::ApiError, + util::error::Context, +}; + +pub const FEE: PayoutMethodFee = PayoutMethodFee { + percentage: dec!(0.02), + min: dec!(0.25), + max: Some(dec!(1.0)), +}; + +#[derive(Debug)] +pub(super) struct PayPalFlow { + is_venmo: bool, + sent_to_user_usd: Decimal2dp, + fee_usd: Decimal2dp, +} + +pub(super) async fn create( + _queue: &PayoutsQueue, + amount: Decimal, + is_venmo: bool, +) -> Result { + let gross_usd = + Decimal2dp::new(amount).wrap_request_err("invalid amount")?; + let fee_usd = Decimal2dp::rounded( + FEE.compute_fee(amount), + RoundingStrategy::AwayFromZero, + ); + let sent_to_user_usd = gross_usd - fee_usd; + + Ok(PayoutFlow { + total_fee: fee_usd.get(), + forex_usd_to_currency: None, + inner: PayoutFlowInner::PayPal(PayPalFlow { + is_venmo, + sent_to_user_usd, + fee_usd, + }), + }) +} + +pub(super) async fn execute( + ExecuteContext { + queue, + user, + payout_id, + db: _, + mut transaction, + gotenberg: _, + }: ExecuteContext<'_>, + PayPalFlow { + is_venmo, + sent_to_user_usd, + fee_usd, + }: PayPalFlow, +) -> Result<(), ApiError> { + #[derive(Deserialize)] + struct PayPalLink { + href: String, + } + + #[derive(Deserialize)] + struct PayoutsResponse { + pub links: Vec, + } + + #[derive(Deserialize)] + struct PayoutItem { + pub payout_item_id: String, + } + + #[derive(Deserialize)] + struct PayoutData { + pub items: Vec, + } + + // keep the `method_id` code here since the big if block below is legacy code + // when we had paypal intl methods as well + let method_id = if is_venmo { "venmo" } else { "paypal_us" }; + + let (wallet, wallet_type, address, display_address) = if is_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" && method_id != "paypal_us" { + return Err(ApiError::InvalidInput( + "Please use the US PayPal transfer option!".to_string(), + )); + } else if paypal_country != "US" && 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.as_ref().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(), + )); + }; + + let payout_req = 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": sent_to_user_usd.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), + }] + }); + + let res: PayoutsResponse = queue + .make_paypal_request( + Method::POST, + "payments/payouts", + Some(payout_req), + None, + None, + ) + .await + .wrap_internal_err("failed to make payout request")?; + + // by this point, we've made a monetary payout request to this user; + // no matter what we do, we *must* track this payout in the DB, + // even if the next steps fail, so that the user's balance is subtracted. + + let mut payout = DBPayout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: sent_to_user_usd.get(), + fee: Some(fee_usd.get()), + method: Some(if is_venmo { + PayoutMethodType::Venmo + } else { + PayoutMethodType::PayPal + }), + method_id: Some(method_id.to_string()), + method_address: Some(display_address.clone()), + platform_id: None, // attempt to populate this later + }; + + // poor man's async try/catch block + let result = (async { + let link = res + .links + .first() + .wrap_request_err("no PayPal links available")?; + + let res = queue + .make_paypal_request::<(), PayoutData>( + Method::GET, + &link.href, + None, + None, + Some(true), + ) + .await + .wrap_internal_err("failed to make PayPal link request")?; + let data = res.items.first().wrap_internal_err( + "no payout items returned from PayPal link request", + )?; + + payout.platform_id = Some(data.payout_item_id.clone()); + Ok::<_, ApiError>(()) + }) + .await; + + if let Err(err) = result { + error!( + "Failed to get PayPal payout platform ID, will track this payout with no platform ID: {err:#}" + ); + } + + payout + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(()) +} diff --git a/apps/labrinth/src/queue/payouts/flow/tremendous.rs b/apps/labrinth/src/queue/payouts/flow/tremendous.rs new file mode 100644 index 0000000000..1f85339d46 --- /dev/null +++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs @@ -0,0 +1,40 @@ +use crate::{queue::payouts::flow::ExecuteContext, routes::ApiError}; + +#[derive(Debug)] +pub(super) struct TremendousFlow {} + +pub(super) async fn create( + queue: &PayoutsQueue, + amount: Decimal, + details: TremendousDetails, +) -> Result { + let forex: TremendousForexResponse = queue + .make_tremendous_request(Method::GET, "forex", None::<()>) + .await + .wrap_internal_err("failed to fetch Tremendous forex data")?; + + let currency = details.currency.unwrap_or(TremendousCurrency::Usd); + let currency_code = currency.to_string(); + let usd_to_currency = forex + .forex + .get(¤cy_code) + .copied() + .wrap_internal_err_with(|| { + eyre!("no Tremendous forex rate for '{currency}'") + })?; + + Ok(PayoutFlow { total_fee }) +} + +pub(super) async fn execute( + ExecuteContext { + queue, + user, + payout_id, + db: _, + mut transaction, + gotenberg: _, + }: ExecuteContext<'_>, + TremendousFlow {}: TremendousFlow, +) -> Result<(), ApiError> { +} diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index b68bcdb057..b4447f547b 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -7,6 +7,7 @@ use crate::models::payouts::{ TremendousForexResponse, }; use crate::models::projects::MonetizationStatus; +use crate::queue::payouts; use crate::queue::payouts::mural::MuralPayoutRequest; use crate::routes::ApiError; use crate::util::env::env_var; @@ -33,9 +34,9 @@ use std::collections::HashMap; use tokio::sync::RwLock; use tracing::{error, info, warn}; -pub mod mural; - mod affiliate; +pub mod flow; +pub mod mural; pub use affiliate::{ process_affiliate_payouts, remove_payouts_for_refunded_charges, }; @@ -447,11 +448,7 @@ impl PayoutsQueue { min: Decimal::from(1) / Decimal::from(4), max: Decimal::from(100_000), }, - fee: PayoutMethodFee { - percentage: Decimal::from(2) / Decimal::from(100), - min: Decimal::from(1) / Decimal::from(4), - max: Some(Decimal::from(1)), - }, + fee: flow::paypal::FEE, currency_code: None, exchange_rate: None, }; @@ -701,8 +698,8 @@ impl PayoutsQueue { RoundingStrategy::AwayFromZero, ); PayoutFees { - method_fee: fee, - platform_fee: Decimal2dp::ZERO, + method_fee: Decimal2dp::ZERO, + platform_fee: fee, exchange_rate: None, } } diff --git a/apps/labrinth/src/queue/payouts/mural.rs b/apps/labrinth/src/queue/payouts/mural.rs index d90d8c7f44..f19e871917 100644 --- a/apps/labrinth/src/queue/payouts/mural.rs +++ b/apps/labrinth/src/queue/payouts/mural.rs @@ -3,7 +3,7 @@ use chrono::Utc; use eyre::{Result, eyre}; use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered}; use modrinth_util::decimal::Decimal2dp; -use muralpay::{MuralError, TokenFeeRequest}; +use muralpay::MuralError; use rust_decimal::{Decimal, prelude::ToPrimitive}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -13,7 +13,7 @@ use crate::{ database::models::DBPayoutId, models::payouts::{PayoutMethodType, PayoutStatus}, queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue}, - routes::ApiError, + routes::{ApiError, internal::gotenberg::GotenbergDocument}, util::{ error::Context, gotenberg::{GotenbergClient, PaymentStatement}, @@ -34,32 +34,63 @@ pub enum MuralPayoutRequest { } impl PayoutsQueue { - pub async fn compute_muralpay_fees( + pub async fn create_mural_payment_statement_doc( &self, - amount: Decimal2dp, - fiat_and_rail_code: muralpay::FiatAndRailCode, - ) -> Result { - let muralpay = self.muralpay.load(); - let muralpay = muralpay - .as_ref() - .wrap_internal_err("Mural Pay client not available")?; + payout_id: DBPayoutId, + net_usd: Decimal2dp, + total_fee_usd: Decimal2dp, + recipient_info: &muralpay::CreatePayoutRecipientInfo, + gotenberg: &GotenbergClient, + ) -> Result { + let gross_usd = net_usd + total_fee_usd; - let fees = muralpay - .client - .get_fees_for_token_amount(&[TokenFeeRequest { - amount: muralpay::TokenAmount { - token_symbol: muralpay::USDC.into(), - token_amount: amount.get(), - }, - fiat_and_rail_code, - }]) + let recipient_address = recipient_info.physical_address(); + let recipient_email = recipient_info.email().to_string(); + let gross_cents = gross_usd.get() * Decimal::from(100); + let net_cents = net_usd.get() * Decimal::from(100); + let fees_cents = total_fee_usd.get() * Decimal::from(100); + let address_line_3 = format!( + "{}, {}, {}", + recipient_address.city, + recipient_address.state, + recipient_address.zip + ); + + let payment_statement = PaymentStatement { + payment_id: payout_id.into(), + recipient_address_line_1: Some(recipient_address.address1.clone()), + recipient_address_line_2: recipient_address.address2.clone(), + recipient_address_line_3: Some(address_line_3), + recipient_email, + payment_date: Utc::now(), + gross_amount_cents: gross_cents + .to_i64() + .wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_cents}` cannot be expressed as an `i64`"))?, + net_amount_cents: net_cents + .to_i64() + .wrap_internal_err_with(|| eyre!("net amount of cents `{net_cents}` cannot be expressed as an `i64`"))?, + fees_cents: fees_cents + .to_i64() + .wrap_internal_err_with(|| eyre!("fees amount of cents `{fees_cents}` cannot be expressed as an `i64`"))?, + currency_code: "USD".into(), + }; + let payment_statement_doc = gotenberg + .wait_for_payment_statement(&payment_statement) .await - .wrap_internal_err("failed to request fees")?; - let fee = fees - .into_iter() - .next() - .wrap_internal_err("no fees returned")?; - Ok(fee) + .wrap_internal_err("failed to generate payment statement")?; + + // TODO + // std::fs::write( + // "/tmp/modrinth-payout-statement.pdf", + // base64::Engine::decode( + // &base64::engine::general_purpose::STANDARD, + // &payment_statement_doc.body, + // ) + // .unwrap(), + // ) + // .unwrap(); + + Ok(payment_statement_doc) } pub async fn create_muralpay_payout_request( diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 3c83d87bf3..a1d61cd2cc 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -8,7 +8,7 @@ use crate::models::ids::PayoutId; use crate::models::pats::Scopes; use crate::models::payouts::{ MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus, - TremendousDetails, TremendousForexResponse, + TremendousCurrency, TremendousDetails, TremendousForexResponse, }; use crate::queue::payouts::mural::MuralPayoutRequest; use crate::queue::payouts::{PayoutFees, PayoutsQueue}; @@ -683,7 +683,7 @@ async fn tremendous_payout( gross_amount: _, fees: _, amount_minus_fee, - total_fee, + total_fee: total_fee_usd, sent_to_method, payouts_queue, db: _, @@ -716,19 +716,32 @@ async fn tremendous_payout( .await .wrap_internal_err("failed to fetch Tremendous forex data")?; - let (denomination, currency_code) = if let Some(currency) = currency { - let currency_code = currency.to_string(); - let exchange_rate = - forex.forex.get(¤cy_code).wrap_internal_err_with(|| { - eyre!("no Tremendous forex data for {currency}") - })?; - ( - sent_to_method.mul_round(*exchange_rate, RoundingStrategy::ToZero), - Some(currency_code), - ) - } else { - (sent_to_method, None) - }; + let currency = currency.unwrap_or(TremendousCurrency::Usd); + let currency_code = currency.to_string(); + let usd_to_currency = forex + .forex + .get(¤cy_code) + .copied() + .wrap_internal_err_with(|| { + eyre!("no Tremendous forex data for {currency}") + })?; + + // withdrawal amount in local currency + let gross_local = body.amount; + // total fee in local currency + let total_fee_local = total_fee_usd * usd_to_currency; + + // let (denomination, currency_code) = if let Some(currency) = currency { + // let currency_code = currency.to_string(); + // let exchange_rate = + + // ( + // sent_to_method.mul_round(*exchange_rate, RoundingStrategy::ToZero), + // Some(currency_code), + // ) + // } else { + // (sent_to_method, None) + // }; let reward_value = if let Some(currency_code) = currency_code { json!({ From 5b7eb791ef5b41a065d836088bacb8f25d194a27 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 31 Dec 2025 01:00:36 +0000 Subject: [PATCH 2/6] working --- apps/labrinth/src/models/v3/payouts.rs | 4 +- apps/labrinth/src/queue/payouts/flow/mod.rs | 87 ++- apps/labrinth/src/queue/payouts/flow/mural.rs | 62 +- .../labrinth/src/queue/payouts/flow/paypal.rs | 21 +- .../src/queue/payouts/flow/tremendous.rs | 272 ++++++++- apps/labrinth/src/queue/payouts/mod.rs | 174 +----- apps/labrinth/src/routes/v3/payouts.rs | 528 +----------------- packages/modrinth-util/src/decimal.rs | 4 + 8 files changed, 433 insertions(+), 719 deletions(-) diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs index d83de44139..574dde676d 100644 --- a/apps/labrinth/src/models/v3/payouts.rs +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -3,7 +3,6 @@ use std::{cmp, collections::HashMap, fmt}; use crate::{models::ids::PayoutId, queue::payouts::mural::MuralPayoutRequest}; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; -use modrinth_util::decimal::Decimal2dp; use rust_decimal::Decimal; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -247,14 +246,13 @@ pub struct PayoutMethod { pub image_url: Option, pub image_logo_url: Option, pub interval: PayoutInterval, - pub fee: PayoutMethodFee, pub currency_code: Option, /// USD to the given `currency_code`. #[serde(with = "rust_decimal::serde::float_option")] pub exchange_rate: Option, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct PayoutMethodFee { #[serde(with = "rust_decimal::serde::float")] pub percentage: Decimal, diff --git a/apps/labrinth/src/queue/payouts/flow/mod.rs b/apps/labrinth/src/queue/payouts/flow/mod.rs index ac58c8b43d..44555cd3c1 100644 --- a/apps/labrinth/src/queue/payouts/flow/mod.rs +++ b/apps/labrinth/src/queue/payouts/flow/mod.rs @@ -1,6 +1,11 @@ +//! Centralized place where payout rails are defined - their fees, minimum and +//! maximum withdraw amounts, and execution logic. + use eyre::eyre; +use modrinth_util::decimal::Decimal2dp; use rust_decimal::Decimal; -use sqlx::{PgPool, PgTransaction}; +use sqlx::PgTransaction; +use thiserror::Error; pub mod mural; pub mod paypal; @@ -15,23 +20,46 @@ use crate::{ }; impl PayoutsQueue { + /// Begins a payout creation flow. + /// + /// A payout creation flow is preparation for sending a user some amount of + /// money, but does not actually send the money until [`PayoutFlow::execute`] + /// is called. This allows callers to get information like the payout fee, + /// minimum, and maximum amounts for validation before actually sending the + /// payout. pub async fn create_payout_flow( &self, withdrawal: Withdrawal, ) -> Result { + let get_method = async { + let method = self + .get_payout_methods() + .await + .wrap_internal_err("failed to fetch payout methods")? + .into_iter() + .find(|method| method.id == withdrawal.method_id) + .wrap_request_err("invalid payout method ID")?; + Ok::<_, ApiError>(method) + }; + match withdrawal.method { PayoutMethodRequest::PayPal => { paypal::create(self, withdrawal.amount, false).await } PayoutMethodRequest::Venmo => { - paypal::create(self, withdrawal.amount, false).await + paypal::create(self, withdrawal.amount, true).await } PayoutMethodRequest::MuralPay { method_details } => { mural::create(self, withdrawal.amount, method_details).await } PayoutMethodRequest::Tremendous { method_details } => { - tremendous::create(self, withdrawal.amount, method_details) - .await + tremendous::create( + self, + withdrawal.amount, + method_details, + &get_method.await?, + ) + .await } } } @@ -39,12 +67,21 @@ impl PayoutsQueue { #[derive(Debug)] pub struct PayoutFlow { - pub total_fee: Decimal, + /// Net amount that the user receives after fees, in USD. + pub net_usd: Decimal2dp, + /// Total payout fee, in USD. + pub total_fee_usd: Decimal2dp, + /// Minimum payout amount, in USD. + pub min_amount_usd: Decimal2dp, + /// Maximum payout amount, in USD. + pub max_amount_usd: Decimal2dp, + /// Currency conversion rate from USD to the payout currency. pub forex_usd_to_currency: Option, inner: PayoutFlowInner, } #[derive(Debug)] +#[expect(clippy::large_enum_variant)] enum PayoutFlowInner { PayPal(paypal::PayPalFlow), Mural(mural::MuralFlow), @@ -54,18 +91,53 @@ enum PayoutFlowInner { struct ExecuteContext<'a> { queue: &'a PayoutsQueue, user: &'a DBUser, - db: &'a PgPool, payout_id: DBPayoutId, transaction: PgTransaction<'a>, gotenberg: &'a GotenbergClient, } +#[derive(Debug)] +pub struct ReadyPayoutFlow { + inner: PayoutFlowInner, +} + +#[derive(Debug, Error)] +pub enum ValidateError { + #[error("insufficient balance")] + InsufficientBalance, + #[error("withdraw amount below minimum")] + BelowMin, + #[error("withdraw amount above maximum")] + AboveMax, +} + impl PayoutFlow { + /// Checks that this payout can be sent if the recipient has the specified + /// balance. + pub fn validate( + self, + balance_usd: Decimal, + ) -> Result { + let gross_usd = self.net_usd + self.total_fee_usd; + if balance_usd < gross_usd { + return Err(ValidateError::InsufficientBalance); + } + if gross_usd < self.min_amount_usd { + return Err(ValidateError::BelowMin); + } + if gross_usd > self.max_amount_usd { + return Err(ValidateError::AboveMax); + } + Ok(ReadyPayoutFlow { inner: self.inner }) + } +} + +impl ReadyPayoutFlow { + /// Executes this payout. pub async fn execute( self, queue: &PayoutsQueue, user: &DBUser, - db: &PgPool, payout_id: DBPayoutId, transaction: PgTransaction<'_>, gotenberg: &GotenbergClient, @@ -73,7 +145,6 @@ impl PayoutFlow { let cx = ExecuteContext { queue, user, - db, payout_id, transaction, gotenberg, diff --git a/apps/labrinth/src/queue/payouts/flow/mural.rs b/apps/labrinth/src/queue/payouts/flow/mural.rs index 7d15f40c40..c8cd5a3672 100644 --- a/apps/labrinth/src/queue/payouts/flow/mural.rs +++ b/apps/labrinth/src/queue/payouts/flow/mural.rs @@ -2,12 +2,15 @@ use ariadne::ids::UserId; use chrono::Utc; use eyre::eyre; use modrinth_util::decimal::Decimal2dp; +use muralpay::FiatAndRailCode; use rust_decimal::{Decimal, RoundingStrategy, dec}; use tracing::error; use crate::{ database::models::payout_item::DBPayout, - models::payouts::{MuralPayDetails, PayoutMethodType, PayoutStatus}, + models::payouts::{ + MuralPayDetails, PayoutMethodFee, PayoutMethodType, PayoutStatus, + }, queue::payouts::{ PayoutsQueue, flow::{ @@ -19,7 +22,26 @@ use crate::{ util::error::Context, }; -pub const OUR_FEE_PERCENT: Decimal = dec!(0.01); +pub const PLATFORM_FEE: PayoutMethodFee = PayoutMethodFee { + percentage: dec!(0.01), + min: Decimal::ZERO, + max: None, +}; + +// USDC has much lower fees. +pub const MIN_USD_BLOCKCHAIN: Decimal2dp = Decimal2dp::new_unchecked(dec!(0.1)); + +pub fn min_usd_fiat(fiat_and_rail_code: FiatAndRailCode) -> Decimal2dp { + match fiat_and_rail_code { + // Due to relatively low volume of Peru withdrawals, fees are higher, + // so we need to raise the minimum to cover these fees. + FiatAndRailCode::UsdPeru => Decimal2dp::new(dec!(10.0)), + _ => Decimal2dp::new(dec!(5.0)), + } + .unwrap() +} + +pub const MAX_USD: Decimal2dp = Decimal2dp::new_unchecked(dec!(10_000.0)); #[derive(Debug)] pub(super) struct MuralFlow { @@ -37,17 +59,26 @@ pub(super) async fn create( ) -> Result { let gross_usd = Decimal2dp::new(amount).wrap_request_err("invalid amount")?; - let platform_fee_usd = - gross_usd.mul_round(OUR_FEE_PERCENT, RoundingStrategy::AwayFromZero); + let platform_fee_usd = Decimal2dp::rounded( + PLATFORM_FEE.compute_fee(gross_usd), + RoundingStrategy::AwayFromZero, + ); let mural = queue.muralpay.load(); let mural = mural .as_ref() .wrap_internal_err("Mural client not available")?; - let (method_fee_usd, forex_usd_to_currency) = match &details.payout_details - { - MuralPayoutRequest::Blockchain { .. } => (Decimal2dp::ZERO, None), + let method_fee_usd; + let forex_usd_to_currency; + let min_amount_usd; + + match &details.payout_details { + MuralPayoutRequest::Blockchain { .. } => { + method_fee_usd = Decimal2dp::ZERO; + forex_usd_to_currency = None; + min_amount_usd = MIN_USD_BLOCKCHAIN; + } MuralPayoutRequest::Fiat { fiat_and_rail_details, .. @@ -75,13 +106,14 @@ pub(super) async fn create( exchange_rate, fee_total, .. - } => ( - Decimal2dp::rounded( + } => { + method_fee_usd = Decimal2dp::rounded( fee_total.token_amount, RoundingStrategy::AwayFromZero, - ), - Some(exchange_rate), - ), + ); + forex_usd_to_currency = Some(exchange_rate); + min_amount_usd = min_usd_fiat(fiat_and_rail_code); + } muralpay::TokenPayoutFee::Error { message, .. } => { return Err(ApiError::Internal(eyre!( "failed to compute fee: {message}" @@ -95,7 +127,10 @@ pub(super) async fn create( let net_usd = gross_usd - total_fee_usd; Ok(PayoutFlow { - total_fee: total_fee_usd.get(), + net_usd, + total_fee_usd, + min_amount_usd, + max_amount_usd: MAX_USD, forex_usd_to_currency, inner: PayoutFlowInner::Mural(MuralFlow { net_usd, @@ -112,7 +147,6 @@ pub(super) async fn execute( queue, user, payout_id, - db: _, mut transaction, gotenberg, }: ExecuteContext<'_>, diff --git a/apps/labrinth/src/queue/payouts/flow/paypal.rs b/apps/labrinth/src/queue/payouts/flow/paypal.rs index aad6436b24..c8e20e279a 100644 --- a/apps/labrinth/src/queue/payouts/flow/paypal.rs +++ b/apps/labrinth/src/queue/payouts/flow/paypal.rs @@ -23,10 +23,13 @@ pub const FEE: PayoutMethodFee = PayoutMethodFee { max: Some(dec!(1.0)), }; +pub const MIN_USD: Decimal2dp = Decimal2dp::new_unchecked(dec!(0.25)); +pub const MAX_USD: Decimal2dp = Decimal2dp::new_unchecked(dec!(100_000.0)); + #[derive(Debug)] pub(super) struct PayPalFlow { is_venmo: bool, - sent_to_user_usd: Decimal2dp, + net_usd: Decimal2dp, fee_usd: Decimal2dp, } @@ -41,14 +44,17 @@ pub(super) async fn create( FEE.compute_fee(amount), RoundingStrategy::AwayFromZero, ); - let sent_to_user_usd = gross_usd - fee_usd; + let net_usd = gross_usd - fee_usd; Ok(PayoutFlow { - total_fee: fee_usd.get(), + net_usd, + total_fee_usd: fee_usd, + min_amount_usd: MIN_USD, + max_amount_usd: MAX_USD, forex_usd_to_currency: None, inner: PayoutFlowInner::PayPal(PayPalFlow { is_venmo, - sent_to_user_usd, + net_usd, fee_usd, }), }) @@ -59,13 +65,12 @@ pub(super) async fn execute( queue, user, payout_id, - db: _, mut transaction, gotenberg: _, }: ExecuteContext<'_>, PayPalFlow { is_venmo, - sent_to_user_usd, + net_usd, fee_usd, }: PayPalFlow, ) -> Result<(), ApiError> { @@ -140,7 +145,7 @@ pub(super) async fn execute( "items": [{ "amount": { "currency": "USD", - "value": sent_to_user_usd.to_string() + "value": net_usd.to_string() }, "receiver": address, "note": "Payment from Modrinth creator monetization program", @@ -170,7 +175,7 @@ pub(super) async fn execute( user_id: user.id, created: Utc::now(), status: PayoutStatus::InTransit, - amount: sent_to_user_usd.get(), + amount: net_usd.get(), fee: Some(fee_usd.get()), method: Some(if is_venmo { PayoutMethodType::Venmo diff --git a/apps/labrinth/src/queue/payouts/flow/tremendous.rs b/apps/labrinth/src/queue/payouts/flow/tremendous.rs index 1f85339d46..eced05520b 100644 --- a/apps/labrinth/src/queue/payouts/flow/tremendous.rs +++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs @@ -1,29 +1,206 @@ -use crate::{queue::payouts::flow::ExecuteContext, routes::ApiError}; +use chrono::Utc; +use eyre::eyre; +use modrinth_util::decimal::Decimal2dp; +use reqwest::Method; +use rust_decimal::{Decimal, RoundingStrategy, dec}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + database::models::payout_item::DBPayout, + models::payouts::{ + PayoutMethod, PayoutMethodFee, PayoutMethodType, PayoutStatus, + TremendousCurrency, TremendousDetails, TremendousForexResponse, + }, + queue::payouts::{ + PayoutsQueue, + flow::{ + ExecuteContext, PayoutFlow, PayoutFlowInner, get_verified_email, + }, + }, + routes::ApiError, + util::error::Context, +}; #[derive(Debug)] -pub(super) struct TremendousFlow {} +pub(super) struct TremendousFlow { + value_denomination: Decimal, + value_currency_code: String, + net_usd: Decimal2dp, + total_fee_usd: Decimal2dp, + delivery_email: String, + method_id: String, +} pub(super) async fn create( queue: &PayoutsQueue, amount: Decimal, details: TremendousDetails, + method: &PayoutMethod, ) -> Result { let forex: TremendousForexResponse = queue .make_tremendous_request(Method::GET, "forex", None::<()>) .await .wrap_internal_err("failed to fetch Tremendous forex data")?; - let currency = details.currency.unwrap_or(TremendousCurrency::Usd); - let currency_code = currency.to_string(); + let category = method.category.as_ref().wrap_internal_err_with(|| { + eyre!("method '{}' should have a category", method.id) + })?; + let currency_code = if let Some(currency_code) = &method.currency_code { + currency_code.clone() + } else { + let currency = details.currency.unwrap_or(TremendousCurrency::Usd); + currency.to_string() + }; + let usd_to_currency = forex .forex .get(¤cy_code) .copied() .wrap_internal_err_with(|| { - eyre!("no Tremendous forex rate for '{currency}'") + eyre!("no Tremendous forex rate for '{currency_code}'") })?; + let currency_to_usd = dec!(1) / usd_to_currency; + + let delivery_email = details.delivery_email; + let method_id = method.id.clone(); + + match category.as_str() { + "paypal" | "venmo" => { + let fee = PayoutMethodFee { + // If a user withdraws $10: + // + // amount charged by Tremendous = X * 1.04 = $10.00 + // + // We have to solve for X here: + // + // X = $10.00 / 1.04 + // + // So the percentage fee is `1 - (1 / 1.04)` + // Roughly 0.03846, not 0.04 + percentage: dec!(1) - (dec!(1) / dec!(1.04)), + min: dec!(0.25), + max: None, + }; + + let gross_usd = + Decimal2dp::new(amount).wrap_request_err("invalid amount")?; + + let total_fee_usd = Decimal2dp::rounded( + fee.compute_fee(amount), + RoundingStrategy::AwayFromZero, + ); + let net_usd = gross_usd - total_fee_usd; + + Ok(PayoutFlow { + net_usd, + total_fee_usd, + min_amount_usd: Decimal2dp::ZERO, + max_amount_usd: Decimal2dp::new(dec!(5000.0)).unwrap(), + forex_usd_to_currency: Some(usd_to_currency), + inner: PayoutFlowInner::Tremendous(TremendousFlow { + // In the Tremendous dashboard, we have configured it so that, + // if we make a $10 request for a premium method, *we* get + // charged an extra 4% - the user gets the full $10, and we get + // $10.40 subtracted from our Tremendous balance. + // + // To offset this, we (the platform) take the fees off before + // we send the request to Tremendous. Afterwards, the method + // (Tremendous) will take 0% off the top of our $10. + value_denomination: net_usd.get(), + value_currency_code: TremendousCurrency::Usd.to_string(), + net_usd, + total_fee_usd, + delivery_email, + method_id, + }), + }) + } + _ => { + // no fees + let net_usd = Decimal2dp::rounded( + amount * currency_to_usd, + RoundingStrategy::AwayFromZero, + ); + + Ok(PayoutFlow { + net_usd, + total_fee_usd: Decimal2dp::ZERO, + min_amount_usd: Decimal2dp::ZERO, + max_amount_usd: Decimal2dp::new(dec!(10_000.0)).unwrap(), + forex_usd_to_currency: Some(usd_to_currency), + inner: PayoutFlowInner::Tremendous(TremendousFlow { + // we have to use the exact `amount` here, + // since interval cards (e.g. PLN 70.00) + // require you to input that exact amount + value_denomination: amount, + value_currency_code: currency_code, + net_usd, + total_fee_usd: Decimal2dp::ZERO, + delivery_email, + method_id, + }), + }) + } + } + + /* + * let method = get_method.await?; + let fee = Decimal2dp::rounded( + method.fee.compute_fee(amount), + RoundingStrategy::AwayFromZero, + ); + + let forex: TremendousForexResponse = self + .make_tremendous_request(Method::GET, "forex", None::<()>) + .await + .wrap_internal_err("failed to fetch Tremendous forex")?; - Ok(PayoutFlow { total_fee }) + let exchange_rate = if let Some(currency) = + &method_details.currency + { + let currency_code = currency.to_string(); + let exchange_rate = + forex.forex.get(¤cy_code).wrap_request_err_with( + || eyre!("no Tremendous forex data for {currency}"), + )?; + Some(*exchange_rate) + } else { + None + }; + + PayoutFees { + method_fee: Decimal2dp::ZERO, + platform_fee: fee, + exchange_rate, + } + */ + + /* + * // https://help.tremendous.com/hc/en-us/articles/41472317536787-Premium-reward-options + let fee = match product.category.as_str() { + "paypal" | "venmo" => PayoutMethodFee { + // If a user withdraws $10: + // + // amount charged by Tremendous = X * 1.04 = $10.00 + // + // We have to solve for X here: + // + // X = $10.00 / 1.04 + // + // So the percentage fee is `1 - (1 / 1.04)` + // Roughly 0.03846, not 0.04 + percentage: dec!(1) - (dec!(1) / dec!(1.04)), + min: dec!(0.25), + max: None, + }, + _ => PayoutMethodFee { + percentage: dec!(0), + min: dec!(0), + max: None, + }, + }; + */ } pub(super) async fn execute( @@ -31,10 +208,89 @@ pub(super) async fn execute( queue, user, payout_id, - db: _, mut transaction, gotenberg: _, }: ExecuteContext<'_>, - TremendousFlow {}: TremendousFlow, + TremendousFlow { + value_denomination, + value_currency_code, + net_usd, + total_fee_usd, + delivery_email, + method_id, + }: TremendousFlow, ) -> Result<(), ApiError> { + #[derive(Debug, Deserialize)] + struct Reward { + pub id: String, + } + + #[derive(Debug, Deserialize)] + struct Order { + pub rewards: Vec, + } + + #[derive(Debug, Deserialize)] + struct TremendousResponse { + pub order: Order, + } + + let user_email = get_verified_email(user)?; + + let order_req = json!({ + "payment": { + "funding_source_id": "BALANCE", + }, + "rewards": [{ + "value": { + "denomination": value_denomination, + "currency_code": value_currency_code, + }, + "delivery": { + "method": "EMAIL" + }, + "recipient": { + "name": user.username, + "email": delivery_email + }, + "products": [ + method_id, + ], + "campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?, + }] + }); + + let order_res: TremendousResponse = queue + .make_tremendous_request(Method::POST, "orders", Some(order_req)) + .await + .wrap_internal_err("failed to make Tremendous order request")?; + + let platform_id = order_res + .order + .rewards + .first() + .map(|reward| reward.id.clone()); + + DBPayout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: net_usd.get(), + fee: Some(total_fee_usd.get()), + method: Some(PayoutMethodType::Tremendous), + method_id: Some(method_id), + method_address: Some(user_email.to_string()), + platform_id, + } + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(()) } diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index b4447f547b..c08eb471e1 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -2,13 +2,10 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::payouts_values_notifications; use crate::database::redis::RedisPool; use crate::models::payouts::{ - MuralPayDetails, PayoutDecimal, PayoutInterval, PayoutMethod, - PayoutMethodFee, PayoutMethodRequest, PayoutMethodType, + PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodType, TremendousForexResponse, }; use crate::models::projects::MonetizationStatus; -use crate::queue::payouts; -use crate::queue::payouts::mural::MuralPayoutRequest; use crate::routes::ApiError; use crate::util::env::env_var; use crate::util::error::Context; @@ -19,12 +16,12 @@ use arc_swap::ArcSwapOption; use base64::Engine; use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; use dashmap::DashMap; -use eyre::{Result, eyre}; +use eyre::Result; use futures::TryStreamExt; use modrinth_util::decimal::Decimal2dp; use reqwest::Method; use rust_decimal::prelude::ToPrimitive; -use rust_decimal::{Decimal, RoundingStrategy, dec}; +use rust_decimal::{Decimal, dec}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -141,6 +138,7 @@ fn create_muralpay_methods() -> Vec { image_logo_url: None, interval: PayoutInterval::Standard { // Different countries and currencies supported by Mural have different fees. + // Therefore, we have different minimum withdraw amounts. min: match id { // Due to relatively low volume of Peru withdrawals, fees are higher, // so we need to raise the minimum to cover these fees. @@ -151,12 +149,7 @@ fn create_muralpay_methods() -> Vec { } _ => Decimal::from(5), }, - max: Decimal::from(10_000), - }, - fee: PayoutMethodFee { - percentage: Decimal::from(1) / Decimal::from(100), - min: Decimal::ZERO, - max: Some(Decimal::ZERO), + max: flow::mural::MAX_USD.get(), }, currency_code: None, exchange_rate: None, @@ -445,10 +438,9 @@ impl PayoutsQueue { image_url: None, image_logo_url: None, interval: PayoutInterval::Standard { - min: Decimal::from(1) / Decimal::from(4), - max: Decimal::from(100_000), + min: flow::paypal::MIN_USD.get(), + max: flow::paypal::MAX_USD.get(), }, - fee: flow::paypal::FEE, currency_code: None, exchange_rate: None, }; @@ -619,133 +611,6 @@ impl PayoutsQueue { / Decimal::from(100), })) } - - pub async fn calculate_fees( - &self, - request: &PayoutMethodRequest, - method_id: &str, - amount: Decimal2dp, - ) -> Result { - const MURAL_FEE: Decimal = dec!(0.01); - - let get_method = async { - let method = self - .get_payout_methods() - .await - .wrap_internal_err("failed to fetch payout methods")? - .into_iter() - .find(|method| method.id == method_id) - .wrap_request_err("invalid payout method ID")?; - Ok::<_, ApiError>(method) - }; - - let fees = match request { - PayoutMethodRequest::MuralPay { - method_details: - MuralPayDetails { - payout_details: MuralPayoutRequest::Blockchain { .. }, - .. - }, - } => PayoutFees { - method_fee: Decimal2dp::ZERO, - platform_fee: amount - .mul_round(MURAL_FEE, RoundingStrategy::AwayFromZero), - exchange_rate: None, - }, - PayoutMethodRequest::MuralPay { - method_details: - MuralPayDetails { - payout_details: - MuralPayoutRequest::Fiat { - fiat_and_rail_details, - .. - }, - .. - }, - } => { - let fiat_and_rail_code = fiat_and_rail_details.code(); - let fee = self - .compute_muralpay_fees(amount, fiat_and_rail_code) - .await?; - - match fee { - muralpay::TokenPayoutFee::Success { - exchange_rate, - fee_total, - .. - } => PayoutFees { - method_fee: Decimal2dp::rounded( - fee_total.token_amount, - RoundingStrategy::AwayFromZero, - ), - platform_fee: amount.mul_round( - MURAL_FEE, - RoundingStrategy::AwayFromZero, - ), - exchange_rate: Some(exchange_rate), - }, - muralpay::TokenPayoutFee::Error { message, .. } => { - return Err(ApiError::Internal(eyre!( - "failed to compute fee: {message}" - ))); - } - } - } - PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => { - let method = get_method.await?; - let fee = Decimal2dp::rounded( - method.fee.compute_fee(amount), - RoundingStrategy::AwayFromZero, - ); - PayoutFees { - method_fee: Decimal2dp::ZERO, - platform_fee: fee, - exchange_rate: None, - } - } - PayoutMethodRequest::Tremendous { method_details } => { - let method = get_method.await?; - let fee = Decimal2dp::rounded( - method.fee.compute_fee(amount), - RoundingStrategy::AwayFromZero, - ); - - let forex: TremendousForexResponse = self - .make_tremendous_request(Method::GET, "forex", None::<()>) - .await - .wrap_internal_err("failed to fetch Tremendous forex")?; - - let exchange_rate = if let Some(currency) = - &method_details.currency - { - let currency_code = currency.to_string(); - let exchange_rate = - forex.forex.get(¤cy_code).wrap_request_err_with( - || eyre!("no Tremendous forex data for {currency}"), - )?; - Some(*exchange_rate) - } else { - None - }; - - // In the Tremendous dashboard, we have configured it so that, - // if we make a $10 request for a premium method, *we* get - // charged an extra 4% - the user gets the full $10, and we get - // $10.40 subtracted from our Tremendous balance. - // - // To offset this, we (the platform) take the fees off before - // we send the request to Tremendous. Afterwards, the method - // (Tremendous) will take 0% off the top of our $10. - PayoutFees { - method_fee: Decimal2dp::ZERO, - platform_fee: fee, - exchange_rate, - } - } - }; - - Ok(fees) - } } #[derive(Debug, Clone, Copy)] @@ -886,30 +751,6 @@ async fn get_tremendous_payout_methods( continue; }; - // https://help.tremendous.com/hc/en-us/articles/41472317536787-Premium-reward-options - let fee = match product.category.as_str() { - "paypal" | "venmo" => PayoutMethodFee { - // If a user withdraws $10: - // - // amount charged by Tremendous = X * 1.04 = $10.00 - // - // We have to solve for X here: - // - // X = $10.00 / 1.04 - // - // So the percentage fee is `1 - (1 / 1.04)` - // Roughly 0.03846, not 0.04 - percentage: dec!(1) - (dec!(1) / dec!(1.04)), - min: dec!(0.25), - max: None, - }, - _ => PayoutMethodFee { - percentage: dec!(0), - min: dec!(0), - max: None, - }, - }; - let Some(currency) = product.currency_codes.first() else { // cards with multiple currencies are not supported continue; @@ -960,7 +801,6 @@ async fn get_tremendous_payout_methods( max: Decimal::from(5_000), } }, - fee, currency_code: Some(currency.clone()), exchange_rate: Some(usd_to_currency), }; diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index a1d61cd2cc..c5ee48adb0 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -1,17 +1,12 @@ use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::{AuthenticationError, get_user_from_headers}; -use crate::database::models::payout_item::DBPayout; -use crate::database::models::{DBPayoutId, DBUser, DBUserId}; +use crate::database::models::DBUserId; use crate::database::models::{generate_payout_id, users_compliance}; use crate::database::redis::RedisPool; use crate::models::ids::PayoutId; use crate::models::pats::Scopes; -use crate::models::payouts::{ - MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus, - TremendousCurrency, TremendousDetails, TremendousForexResponse, -}; -use crate::queue::payouts::mural::MuralPayoutRequest; -use crate::queue::payouts::{PayoutFees, PayoutsQueue}; +use crate::models::payouts::{PayoutMethodType, PayoutStatus, Withdrawal}; +use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::avalara1099; @@ -19,16 +14,13 @@ use crate::util::error::Context; use crate::util::gotenberg::GotenbergClient; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use chrono::{DateTime, Duration, Utc}; -use eyre::eyre; use hex::ToHex; use hmac::{Hmac, Mac}; -use modrinth_util::decimal::Decimal2dp; use reqwest::Method; -use rust_decimal::{Decimal, RoundingStrategy}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use serde_json::json; use sha2::Sha256; -use sqlx::{PgPool, PgTransaction}; +use sqlx::PgPool; use std::collections::HashMap; use tokio_stream::StreamExt; use tracing::error; @@ -422,17 +414,9 @@ pub async fn tremendous_webhook( Ok(HttpResponse::NoContent().finish()) } -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] -pub struct Withdrawal { - amount: Decimal2dp, - #[serde(flatten)] - method: PayoutMethodRequest, - method_id: String, -} - #[derive(Debug, Serialize, Deserialize)] pub struct WithdrawalFees { - pub fee: Decimal2dp, + pub fee: Decimal, pub exchange_rate: Option, } @@ -459,13 +443,11 @@ pub async fn calculate_fees( ApiError::Authentication(AuthenticationError::InvalidCredentials) })?; - let fees = payouts_queue - .calculate_fees(&body.method, &body.method_id, body.amount) - .await?; + let payout_flow = payouts_queue.create_payout_flow(body.0).await?; Ok(web::Json(WithdrawalFees { - fee: fees.total_fee(), - exchange_rate: fees.exchange_rate, + fee: payout_flow.total_fee_usd.get(), + exchange_rate: payout_flow.forex_usd_to_currency, })) } @@ -581,63 +563,19 @@ pub async fn create_payout( )); } - let fees = payouts_queue - .calculate_fees(&body.method, &body.method_id, body.amount) - .await - .wrap_internal_err("failed to compute fees")?; - - // fees are a bit complicated here, since we have 2 types: - // - method fees - this is what Tremendous, Mural, etc. will take from us - // without us having a say in it - // - platform fees - this is what we deliberately keep for ourselves - // - total fees - method fees + platform fees - // - // we first make sure that `amount - total fees` is greater than zero, - // then we issue a payout request with `amount - platform fees` - - let amount_minus_fee = body.amount - fees.total_fee(); - if amount_minus_fee <= Decimal::ZERO { - return Err(ApiError::InvalidInput( - "You need to withdraw more to cover the fee!".to_string(), - )); - } - - let sent_to_method = body.amount - fees.platform_fee; - if sent_to_method <= Decimal::ZERO { - return Err(ApiError::InvalidInput( - "You need to withdraw more to cover the fee!".to_string(), - )); - } + let payout_flow = payouts_queue.create_payout_flow(body.0).await?; + let payout_flow = match payout_flow.validate(balance.available) { + Ok(flow) => flow, + Err(err) => return Err(ApiError::InvalidInput(err.to_string())), + }; let payout_id = generate_payout_id(&mut transaction) .await .wrap_internal_err("failed to generate payout ID")?; - let payout_cx = PayoutContext { - body: &body, - user: &user, - payout_id, - gross_amount: body.amount, - fees, - amount_minus_fee, - total_fee: fees.total_fee(), - sent_to_method, - payouts_queue: &payouts_queue, - db: PgPool::clone(&pool), - transaction, - }; - - match &body.method { - PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => { - paypal_payout(payout_cx).await?; - } - PayoutMethodRequest::Tremendous { method_details } => { - tremendous_payout(payout_cx, method_details).await?; - } - PayoutMethodRequest::MuralPay { method_details } => { - mural_pay_payout(payout_cx, method_details, &gotenberg).await?; - } - } + payout_flow + .execute(&payouts_queue, &user, payout_id, transaction, &gotenberg) + .await?; crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) .await @@ -646,438 +584,6 @@ pub async fn create_payout( Ok(()) } -struct PayoutContext<'a> { - body: &'a Withdrawal, - user: &'a DBUser, - payout_id: DBPayoutId, - gross_amount: Decimal2dp, - fees: PayoutFees, - /// Set as the [`DBPayout::amount`] field. - amount_minus_fee: Decimal2dp, - /// Set as the [`DBPayout::fee`] field. - total_fee: Decimal2dp, - sent_to_method: Decimal2dp, - payouts_queue: &'a PayoutsQueue, - db: PgPool, - transaction: PgTransaction<'a>, -} - -fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> { - let email = user.email.as_ref().wrap_request_err( - "you must add an email to your account to withdraw", - )?; - if !user.email_verified { - return Err(ApiError::Request(eyre!( - "you must verify your email to withdraw" - ))); - } - - Ok(email) -} - -async fn tremendous_payout( - PayoutContext { - body, - user, - payout_id, - gross_amount: _, - fees: _, - amount_minus_fee, - total_fee: total_fee_usd, - sent_to_method, - payouts_queue, - db: _, - mut transaction, - }: PayoutContext<'_>, - TremendousDetails { - delivery_email, - currency, - }: &TremendousDetails, -) -> Result<(), ApiError> { - let user_email = get_verified_email(user)?; - - #[derive(Deserialize)] - struct Reward { - pub id: String, - } - - #[derive(Deserialize)] - struct Order { - pub rewards: Vec, - } - - #[derive(Deserialize)] - struct TremendousResponse { - pub order: Order, - } - - let forex: TremendousForexResponse = payouts_queue - .make_tremendous_request(Method::GET, "forex", None::<()>) - .await - .wrap_internal_err("failed to fetch Tremendous forex data")?; - - let currency = currency.unwrap_or(TremendousCurrency::Usd); - let currency_code = currency.to_string(); - let usd_to_currency = forex - .forex - .get(¤cy_code) - .copied() - .wrap_internal_err_with(|| { - eyre!("no Tremendous forex data for {currency}") - })?; - - // withdrawal amount in local currency - let gross_local = body.amount; - // total fee in local currency - let total_fee_local = total_fee_usd * usd_to_currency; - - // let (denomination, currency_code) = if let Some(currency) = currency { - // let currency_code = currency.to_string(); - // let exchange_rate = - - // ( - // sent_to_method.mul_round(*exchange_rate, RoundingStrategy::ToZero), - // Some(currency_code), - // ) - // } else { - // (sent_to_method, None) - // }; - - let reward_value = if let Some(currency_code) = currency_code { - json!({ - "denomination": denomination, - "currency_code": currency_code, - }) - } else { - json!({ - "denomination": denomination, - }) - }; - - let res: TremendousResponse = payouts_queue - .make_tremendous_request( - Method::POST, - "orders", - Some(json! ({ - "payment": { - "funding_source_id": "BALANCE", - }, - "rewards": [{ - "value": reward_value, - "delivery": { - "method": "EMAIL" - }, - "recipient": { - "name": user.username, - "email": delivery_email - }, - "products": [ - &body.method_id, - ], - "campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?, - }] - })), - ) - .await?; - - let platform_id = res.order.rewards.first().map(|reward| reward.id.clone()); - - DBPayout { - id: payout_id, - user_id: user.id, - created: Utc::now(), - status: PayoutStatus::InTransit, - amount: amount_minus_fee.get(), - fee: Some(total_fee.get()), - method: Some(PayoutMethodType::Tremendous), - method_id: Some(body.method_id.clone()), - method_address: Some(user_email.to_string()), - platform_id, - } - .insert(&mut transaction) - .await - .wrap_internal_err("failed to insert payout")?; - - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; - - Ok(()) -} - -async fn mural_pay_payout( - PayoutContext { - body: _, - user, - payout_id, - gross_amount, - fees, - amount_minus_fee, - total_fee, - sent_to_method: _, - payouts_queue, - db, - mut transaction, - }: PayoutContext<'_>, - details: &MuralPayDetails, - gotenberg: &GotenbergClient, -) -> Result<(), ApiError> { - let user_email = get_verified_email(user)?; - - let method_id = match &details.payout_details { - MuralPayoutRequest::Blockchain { .. } => { - "blockchain-usdc-polygon".to_string() - } - MuralPayoutRequest::Fiat { - fiat_and_rail_details, - .. - } => fiat_and_rail_details.code().to_string(), - }; - - // Once the Mural payout request has been created successfully, - // then we *must* commit the payout into the DB, - // to link the Mural payout request to the `payout` row. - // Even if we can't execute the payout. - // For this, we immediately insert and commit the txn. - // Otherwise if we don't put it into the DB, we've got a ghost Mural - // payout with no related database entry. - // - // However, this doesn't mean that the payout will definitely go through. - // For this, we need to execute it, and handle errors. - - let payout_request = payouts_queue - .create_muralpay_payout_request( - payout_id, - user.id.into(), - gross_amount, - fees, - details.payout_details.clone(), - details.recipient_info.clone(), - gotenberg, - ) - .await?; - - let payout = DBPayout { - id: payout_id, - user_id: user.id, - created: Utc::now(), - // after the payout has been successfully executed, - // we wait for Mural's confirmation that the funds have been delivered - // done in `SyncPayoutStatuses` background task - status: PayoutStatus::InTransit, - amount: amount_minus_fee.get(), - fee: Some(total_fee.get()), - method: Some(PayoutMethodType::MuralPay), - method_id: Some(method_id), - method_address: Some(user_email.to_string()), - platform_id: Some(payout_request.id.to_string()), - }; - payout - .insert(&mut transaction) - .await - .wrap_internal_err("failed to insert payout")?; - - transaction - .commit() - .await - .wrap_internal_err("failed to commit payout insert transaction")?; - - // try to immediately execute the payout request... - // use a poor man's try/catch block using this `async move {}` - // to catch any errors within this block - let result = async move { - payouts_queue - .execute_mural_payout_request(payout_request.id) - .await - .wrap_internal_err("failed to execute payout request")?; - eyre::Ok(()) - } - .await; - - // and if it fails, make sure to immediately cancel it - - // we don't want floating payout requests - if let Err(err) = result { - if let Err(err) = sqlx::query!( - " - UPDATE payouts - SET status = $1 - WHERE id = $2 - ", - PayoutStatus::Failed.as_str(), - payout.id as _, - ) - .execute(&db) - .await - { - error!( - "Created a Mural payout request, but failed to execute it, \ - and failed to mark the payout as failed: {err:#?}" - ); - } - - payouts_queue - .cancel_mural_payout_request(payout_request.id) - .await - .wrap_internal_err_with(|| { - eyre!("failed to cancel unexecuted payout request\noriginal error: {err:#?}") - })?; - - return Err(ApiError::Internal(err)); - } - - Ok(()) -} - -async fn paypal_payout( - PayoutContext { - body, - user, - payout_id, - gross_amount: _, - fees: _, - amount_minus_fee, - total_fee, - sent_to_method, - payouts_queue, - db: _, - mut transaction, - }: PayoutContext<'_>, -) -> Result<(), ApiError> { - let (wallet, wallet_type, address, display_address) = - if matches!(body.method, PayoutMethodRequest::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.as_ref().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, - } - - 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": sent_to_method.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?; - - let link = res - .links - .first() - .wrap_request_err("no PayPal links available")?; - - #[derive(Deserialize)] - struct PayoutItem { - pub payout_item_id: String, - } - - #[derive(Deserialize)] - struct PayoutData { - pub items: Vec, - } - - let res = payouts_queue - .make_paypal_request::<(), PayoutData>( - Method::GET, - &link.href, - None, - None, - Some(true), - ) - .await - .wrap_internal_err("failed to make PayPal request")?; - let data = res - .items - .first() - .wrap_internal_err("no payout items returned from PayPal request")?; - - let platform_id = Some(data.payout_item_id.clone()); - - DBPayout { - id: payout_id, - user_id: user.id, - created: Utc::now(), - status: PayoutStatus::InTransit, - amount: amount_minus_fee.get(), - fee: Some(total_fee.get()), - method: Some(body.method.method_type()), - method_id: Some(body.method_id.clone()), - method_address: Some(display_address.clone()), - platform_id, - } - .insert(&mut transaction) - .await - .wrap_internal_err("failed to insert payout")?; - - transaction - .commit() - .await - .wrap_internal_err("failed to commit transaction")?; - - Ok(()) -} - /// User performing a payout-related action. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] diff --git a/packages/modrinth-util/src/decimal.rs b/packages/modrinth-util/src/decimal.rs index ec5771b38a..dcff01bf75 100644 --- a/packages/modrinth-util/src/decimal.rs +++ b/packages/modrinth-util/src/decimal.rs @@ -48,6 +48,10 @@ impl DecimalDp { } } + pub const fn new_unchecked(v: Decimal) -> Self { + Self(v) + } + pub fn get(self) -> Decimal { self.0 } From 17c02429bbad9299e271358b650b36fd6f10a342 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 31 Dec 2025 10:02:56 +0000 Subject: [PATCH 3/6] Finish up flow migration --- ...f4eeff66ab4165a9f4980032e114db4dc1286.json | 26 ++++ ...30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json | 15 -- ...d2402f52fea71e27b08e7926fcc2a9e62c0f3.json | 20 +++ ...afedb074492b4ec7f2457c14113f5fd13aa02.json | 18 +++ ...e5c93783c7641b019fdb698a1ec0be1393606.json | 17 ++ apps/labrinth/src/queue/payouts/flow/mural.rs | 5 +- .../src/queue/payouts/flow/tremendous.rs | 95 +++--------- apps/labrinth/src/queue/payouts/mod.rs | 89 ++++++----- apps/labrinth/src/queue/payouts/mural.rs | 145 +----------------- apps/labrinth/src/routes/v3/payouts.rs | 7 +- 10 files changed, 160 insertions(+), 277 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json delete mode 100644 apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json create mode 100644 apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json create mode 100644 apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json create mode 100644 apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json new file mode 100644 index 0000000000..921f7f92d9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "status: PayoutStatus", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" +} diff --git a/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json b/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json deleted file mode 100644 index e6a6c41d17..0000000000 --- a/apps/labrinth/.sqlx/query-76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE payouts\n SET status = $1\n WHERE id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "76cf88116014e3151db2f85b0bd30dba70ae62f9f922ed711dbb85fcf4ec5f7c" -} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json new file mode 100644 index 0000000000..89bd8147dc --- /dev/null +++ b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status: PayoutStatus", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" +} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json new file mode 100644 index 0000000000..469c30168a --- /dev/null +++ b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" +} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json new file mode 100644 index 0000000000..52e020ebf2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" +} diff --git a/apps/labrinth/src/queue/payouts/flow/mural.rs b/apps/labrinth/src/queue/payouts/flow/mural.rs index c8cd5a3672..86cb2afc32 100644 --- a/apps/labrinth/src/queue/payouts/flow/mural.rs +++ b/apps/labrinth/src/queue/payouts/flow/mural.rs @@ -272,8 +272,9 @@ pub(super) async fn execute( // poor man's async try/catch block let result = (async { - queue - .execute_mural_payout_request(payout_request.id) + mural + .client + .execute_payout_request(payout_request.id) .await .wrap_internal_err("failed to execute payout request")?; Ok::<_, ApiError>(()) diff --git a/apps/labrinth/src/queue/payouts/flow/tremendous.rs b/apps/labrinth/src/queue/payouts/flow/tremendous.rs index eced05520b..c5b2cc1ab3 100644 --- a/apps/labrinth/src/queue/payouts/flow/tremendous.rs +++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs @@ -42,31 +42,29 @@ pub(super) async fn create( .make_tremendous_request(Method::GET, "forex", None::<()>) .await .wrap_internal_err("failed to fetch Tremendous forex data")?; + let usd_to_currency_for = |currency_code: &str| { + forex + .forex + .get(currency_code) + .copied() + .wrap_internal_err_with(|| { + eyre!("no Tremendous forex rate for '{currency_code}'") + }) + }; let category = method.category.as_ref().wrap_internal_err_with(|| { eyre!("method '{}' should have a category", method.id) })?; - let currency_code = if let Some(currency_code) = &method.currency_code { - currency_code.clone() - } else { - let currency = details.currency.unwrap_or(TremendousCurrency::Usd); - currency.to_string() - }; - - let usd_to_currency = forex - .forex - .get(¤cy_code) - .copied() - .wrap_internal_err_with(|| { - eyre!("no Tremendous forex rate for '{currency_code}'") - })?; - let currency_to_usd = dec!(1) / usd_to_currency; let delivery_email = details.delivery_email; let method_id = method.id.clone(); match category.as_str() { "paypal" | "venmo" => { + let currency = details.currency.unwrap_or(TremendousCurrency::Usd); + let currency_code = currency.to_string(); + let usd_to_currency = usd_to_currency_for(¤cy_code)?; + let fee = PayoutMethodFee { // If a user withdraws $10: // @@ -117,6 +115,15 @@ pub(super) async fn create( }) } _ => { + let currency_code = + if let Some(currency_code) = &method.currency_code { + currency_code.clone() + } else { + TremendousCurrency::Usd.to_string() + }; + let usd_to_currency = usd_to_currency_for(¤cy_code)?; + let currency_to_usd = dec!(1) / usd_to_currency; + // no fees let net_usd = Decimal2dp::rounded( amount * currency_to_usd, @@ -143,64 +150,6 @@ pub(super) async fn create( }) } } - - /* - * let method = get_method.await?; - let fee = Decimal2dp::rounded( - method.fee.compute_fee(amount), - RoundingStrategy::AwayFromZero, - ); - - let forex: TremendousForexResponse = self - .make_tremendous_request(Method::GET, "forex", None::<()>) - .await - .wrap_internal_err("failed to fetch Tremendous forex")?; - - let exchange_rate = if let Some(currency) = - &method_details.currency - { - let currency_code = currency.to_string(); - let exchange_rate = - forex.forex.get(¤cy_code).wrap_request_err_with( - || eyre!("no Tremendous forex data for {currency}"), - )?; - Some(*exchange_rate) - } else { - None - }; - - PayoutFees { - method_fee: Decimal2dp::ZERO, - platform_fee: fee, - exchange_rate, - } - */ - - /* - * // https://help.tremendous.com/hc/en-us/articles/41472317536787-Premium-reward-options - let fee = match product.category.as_str() { - "paypal" | "venmo" => PayoutMethodFee { - // If a user withdraws $10: - // - // amount charged by Tremendous = X * 1.04 = $10.00 - // - // We have to solve for X here: - // - // X = $10.00 / 1.04 - // - // So the percentage fee is `1 - (1 / 1.04)` - // Roughly 0.03846, not 0.04 - percentage: dec!(1) - (dec!(1) / dec!(1.04)), - min: dec!(0.25), - max: None, - }, - _ => PayoutMethodFee { - percentage: dec!(0), - min: dec!(0), - max: None, - }, - }; - */ } pub(super) async fn execute( diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index c08eb471e1..1402df6a80 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -19,9 +19,10 @@ use dashmap::DashMap; use eyre::Result; use futures::TryStreamExt; use modrinth_util::decimal::Decimal2dp; +use muralpay::FiatAndRailCode; use reqwest::Method; +use rust_decimal::Decimal; use rust_decimal::prelude::ToPrimitive; -use rust_decimal::{Decimal, dec}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -100,18 +101,28 @@ fn create_muralpay_methods() -> Vec { .collect::>(); let currencies = vec![ - ("blockchain_usdc_polygon", "USDC on Polygon", all_countries), - ("fiat_mxn", "MXN", vec!["MX"]), - ("fiat_brl", "BRL", vec!["BR"]), - ("fiat_clp", "CLP", vec!["CL"]), - ("fiat_crc", "CRC", vec!["CR"]), - ("fiat_pen", "PEN", vec!["PE"]), + ( + "blockchain_usdc_polygon", + "USDC on Polygon", + all_countries, + None, + ), + ("fiat_mxn", "MXN", vec!["MX"], Some(FiatAndRailCode::Mxn)), + ("fiat_brl", "BRL", vec!["BR"], Some(FiatAndRailCode::Brl)), + ("fiat_clp", "CLP", vec!["CL"], Some(FiatAndRailCode::Clp)), + ("fiat_crc", "CRC", vec!["CR"], Some(FiatAndRailCode::Crc)), + ("fiat_pen", "PEN", vec!["PE"], Some(FiatAndRailCode::Pen)), // ("fiat_dop", "DOP"), // unsupported in API // ("fiat_uyu", "UYU"), // unsupported in API - ("fiat_ars", "ARS", vec!["AR"]), - ("fiat_cop", "COP", vec!["CO"]), - ("fiat_usd", "USD", vec!["US"]), - ("fiat_usd-peru", "USD Peru", vec!["PE"]), + ("fiat_ars", "ARS", vec!["AR"], Some(FiatAndRailCode::Ars)), + ("fiat_cop", "COP", vec!["CO"], Some(FiatAndRailCode::Cop)), + ("fiat_usd", "USD", vec!["US"], Some(FiatAndRailCode::Usd)), + ( + "fiat_usd-peru", + "USD Peru", + vec!["PE"], + Some(FiatAndRailCode::UsdPeru), + ), // ("fiat_usd-panama", "USD Panama"), // by request ( "fiat_eur", @@ -120,40 +131,37 @@ fn create_muralpay_methods() -> Vec { "DE", "FR", "IT", "ES", "NL", "BE", "AT", "PT", "FI", "IE", "GR", "LU", "CY", "MT", "SK", "SI", "EE", "LV", "LT", ], + Some(FiatAndRailCode::Eur), ), ]; currencies .into_iter() - .map(|(id, currency, countries)| PayoutMethod { - id: id.to_string(), - type_: PayoutMethodType::MuralPay, - name: format!("Mural Pay - {currency}"), - category: None, - supported_countries: countries - .iter() - .map(|s| s.to_string()) - .collect(), - image_url: None, - image_logo_url: None, - interval: PayoutInterval::Standard { - // Different countries and currencies supported by Mural have different fees. - // Therefore, we have different minimum withdraw amounts. - min: match id { - // Due to relatively low volume of Peru withdrawals, fees are higher, - // so we need to raise the minimum to cover these fees. - "fiat_usd-peru" => Decimal::from(10), - // USDC has much lower fees. - "blockchain_usdc_polygon" => { - Decimal::from(10) / Decimal::from(100) + .map( + |(id, currency, countries, fiat_and_rail_code)| PayoutMethod { + id: id.to_string(), + type_: PayoutMethodType::MuralPay, + name: format!("Mural Pay - {currency}"), + category: None, + supported_countries: countries + .iter() + .map(|s| s.to_string()) + .collect(), + image_url: None, + image_logo_url: None, + interval: PayoutInterval::Standard { + min: if let Some(fiat_and_rail_code) = fiat_and_rail_code { + flow::mural::min_usd_fiat(fiat_and_rail_code) + } else { + flow::mural::MIN_USD_BLOCKCHAIN } - _ => Decimal::from(5), + .get(), + max: flow::mural::MAX_USD.get(), }, - max: flow::mural::MAX_USD.get(), + currency_code: None, + exchange_rate: None, }, - currency_code: None, - exchange_rate: None, - }) + ) .collect() } @@ -759,7 +767,6 @@ async fn get_tremendous_payout_methods( warn!("No Tremendous forex data for {currency}"); continue; }; - let currency_to_usd = dec!(1) / usd_to_currency; let method = PayoutMethod { id: product.id, @@ -785,15 +792,15 @@ async fn get_tremendous_payout_methods( let mut values = product .skus .into_iter() - .map(|x| PayoutDecimal(x.min * currency_to_usd)) + .map(|x| PayoutDecimal(x.min)) .collect::>(); values.sort_by(|a, b| a.0.cmp(&b.0)); PayoutInterval::Fixed { values } } else if let Some(first) = product.skus.first() { PayoutInterval::Standard { - min: first.min * currency_to_usd, - max: first.max * currency_to_usd, + min: first.min, + max: first.max, } } else { PayoutInterval::Standard { diff --git a/apps/labrinth/src/queue/payouts/mural.rs b/apps/labrinth/src/queue/payouts/mural.rs index f19e871917..633da08118 100644 --- a/apps/labrinth/src/queue/payouts/mural.rs +++ b/apps/labrinth/src/queue/payouts/mural.rs @@ -1,9 +1,7 @@ -use ariadne::ids::UserId; use chrono::Utc; use eyre::{Result, eyre}; use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered}; use modrinth_util::decimal::Decimal2dp; -use muralpay::MuralError; use rust_decimal::{Decimal, prelude::ToPrimitive}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -12,7 +10,7 @@ use tracing::{info, trace, warn}; use crate::{ database::models::DBPayoutId, models::payouts::{PayoutMethodType, PayoutStatus}, - queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue}, + queue::payouts::{AccountBalance, PayoutsQueue}, routes::{ApiError, internal::gotenberg::GotenbergDocument}, util::{ error::Context, @@ -93,147 +91,6 @@ impl PayoutsQueue { Ok(payment_statement_doc) } - pub async fn create_muralpay_payout_request( - &self, - payout_id: DBPayoutId, - user_id: UserId, - gross_amount: Decimal2dp, - fees: PayoutFees, - payout_details: MuralPayoutRequest, - recipient_info: muralpay::CreatePayoutRecipientInfo, - gotenberg: &GotenbergClient, - ) -> Result { - let muralpay = self.muralpay.load(); - let muralpay = muralpay - .as_ref() - .wrap_internal_err("Mural Pay client not available")?; - - let payout_details = match payout_details { - crate::queue::payouts::mural::MuralPayoutRequest::Fiat { - bank_name, - bank_account_owner, - fiat_and_rail_details, - } => muralpay::CreatePayoutDetails::Fiat { - bank_name, - bank_account_owner, - developer_fee: None, - fiat_and_rail_details, - }, - crate::queue::payouts::mural::MuralPayoutRequest::Blockchain { - wallet_address, - } => { - muralpay::CreatePayoutDetails::Blockchain { - wallet_details: muralpay::WalletDetails { - // only Polygon chain is currently supported - blockchain: muralpay::Blockchain::Polygon, - wallet_address, - }, - } - } - }; - - // Mural takes `fees.method_fee` off the top of the amount we tell them to send - let sent_to_method = gross_amount - fees.platform_fee; - // ..so the net is `gross - platform_fee - method_fee` - let net_amount = gross_amount - fees.total_fee(); - - let recipient_address = recipient_info.physical_address(); - let recipient_email = recipient_info.email().to_string(); - let gross_amount_cents = gross_amount.get() * Decimal::from(100); - let net_amount_cents = net_amount.get() * Decimal::from(100); - let fees_cents = fees.total_fee().get() * Decimal::from(100); - let address_line_3 = format!( - "{}, {}, {}", - recipient_address.city, - recipient_address.state, - recipient_address.zip - ); - - let payment_statement = PaymentStatement { - payment_id: payout_id.into(), - recipient_address_line_1: Some(recipient_address.address1.clone()), - recipient_address_line_2: recipient_address.address2.clone(), - recipient_address_line_3: Some(address_line_3), - recipient_email, - payment_date: Utc::now(), - gross_amount_cents: gross_amount_cents - .to_i64() - .wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_amount_cents}` cannot be expressed as an `i64`"))?, - net_amount_cents: net_amount_cents - .to_i64() - .wrap_internal_err_with(|| eyre!("net amount of cents `{net_amount_cents}` cannot be expressed as an `i64`"))?, - fees_cents: fees_cents - .to_i64() - .wrap_internal_err_with(|| eyre!("fees amount of cents `{fees_cents}` cannot be expressed as an `i64`"))?, - currency_code: "USD".into(), - }; - let payment_statement_doc = gotenberg - .wait_for_payment_statement(&payment_statement) - .await - .wrap_internal_err("failed to generate payment statement")?; - - // TODO - // std::fs::write( - // "/tmp/modrinth-payout-statement.pdf", - // base64::Engine::decode( - // &base64::engine::general_purpose::STANDARD, - // &payment_statement_doc.body, - // ) - // .unwrap(), - // ) - // .unwrap(); - - let payout = muralpay::CreatePayout { - amount: muralpay::TokenAmount { - token_amount: sent_to_method.get(), - token_symbol: muralpay::USDC.into(), - }, - payout_details, - recipient_info, - supporting_details: Some(muralpay::SupportingDetails { - supporting_document: Some(format!( - "data:application/pdf;base64,{}", - payment_statement_doc.body - )), - payout_purpose: Some(muralpay::PayoutPurpose::VendorPayment), - }), - }; - - let payout_request = muralpay - .client - .create_payout_request( - muralpay.source_account_id, - Some(format!("User {user_id}")), - &[payout], - ) - .await - .map_err(|err| match err { - MuralError::Api(err) => ApiError::Mural(Box::new(err)), - err => ApiError::Internal( - eyre!(err).wrap_err("failed to create payout request"), - ), - })?; - - Ok(payout_request) - } - - pub async fn execute_mural_payout_request( - &self, - id: muralpay::PayoutRequestId, - ) -> Result<(), ApiError> { - let muralpay = self.muralpay.load(); - let muralpay = muralpay - .as_ref() - .wrap_internal_err("Mural Pay client not available")?; - - muralpay - .client - .execute_payout_request(id) - .await - .wrap_internal_err("failed to execute payout request")?; - Ok(()) - } - pub async fn cancel_mural_payout_request( &self, id: muralpay::PayoutRequestId, diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index c5ee48adb0..0ed13f2c63 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -16,6 +16,7 @@ use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use chrono::{DateTime, Duration, Utc}; use hex::ToHex; use hmac::{Hmac, Mac}; +use modrinth_util::decimal::Decimal2dp; use reqwest::Method; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -416,7 +417,8 @@ pub async fn tremendous_webhook( #[derive(Debug, Serialize, Deserialize)] pub struct WithdrawalFees { - pub fee: Decimal, + pub net_usd: Decimal2dp, + pub fee: Decimal2dp, pub exchange_rate: Option, } @@ -446,7 +448,8 @@ pub async fn calculate_fees( let payout_flow = payouts_queue.create_payout_flow(body.0).await?; Ok(web::Json(WithdrawalFees { - fee: payout_flow.total_fee_usd.get(), + net_usd: payout_flow.net_usd, + fee: payout_flow.total_fee_usd, exchange_rate: payout_flow.forex_usd_to_currency, })) } From c1ed26edae3a1b6c6460a8f223db6401b6e57aa9 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 31 Dec 2025 10:06:42 +0000 Subject: [PATCH 4/6] vibe-coded frontend changes --- .../ui/dashboard/WithdrawFeeBreakdown.vue | 47 ++++++++++++++++--- .../TremendousDetailsStage.vue | 33 +++++++++---- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue b/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue index c6b3337912..2925af1138 100644 --- a/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue +++ b/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue @@ -11,15 +11,13 @@ @@ -29,7 +27,7 @@ - + @@ -79,9 +77,23 @@ const props = withDefaults( const { formatMessage } = useVIntl() +const amountInUsd = computed(() => { + if (props.isGiftCard && shouldShowExchangeRate.value) { + return (props.amount || 0) / (props.exchangeRate || 1) + } + return props.amount || 0 +}) + +const feeInUsd = computed(() => { + if (props.isGiftCard && shouldShowExchangeRate.value) { + return (props.fee || 0) / (props.exchangeRate || 1) + } + return props.fee || 0 +}) + const netAmount = computed(() => { - const amount = props.amount || 0 - const fee = props.fee || 0 + const amount = amountInUsd.value + const fee = feeInUsd.value return Math.max(0, amount - fee) }) @@ -96,6 +108,11 @@ const netAmountInLocalCurrency = computed(() => { return netAmount.value * (props.exchangeRate || 0) }) +const localCurrencyAmount = computed(() => { + if (!shouldShowExchangeRate.value) return null + return (props.amount || 0) +}) + const formattedLocalCurrency = computed(() => { if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency) return '' @@ -112,6 +129,22 @@ const formattedLocalCurrency = computed(() => { } }) +const formattedLocalCurrencyAmount = computed(() => { + if (!shouldShowExchangeRate.value || !localCurrencyAmount.value || !props.localCurrency) + return '' + + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: props.localCurrency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(localCurrencyAmount.value) + } catch { + return `${props.localCurrency} ${localCurrencyAmount.value.toFixed(2)}` + } +}) + const messages = defineMessages({ feeBreakdownAmount: { id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount', diff --git a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue index 9ba5355775..660744717b 100644 --- a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue +++ b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue @@ -90,7 +90,12 @@ - {{ formatMoney(fixedDenominationMin ?? effectiveMinAmount) + {{ + formatMoney( + selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD' && selectedMethodExchangeRate + ? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate + : (fixedDenominationMin ?? effectiveMinAmount), + ) }}