diff --git a/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue b/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue
index c6b3337912..13c2c994cf 100644
--- a/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue
+++ b/apps/frontend/src/components/ui/dashboard/WithdrawFeeBreakdown.vue
@@ -12,14 +12,14 @@
{{ formatMessage(messages.feeBreakdownGiftCardValue) }}
{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }}){{ formatMoney(amountInUsd) }} ({{ formattedLocalCurrencyAmount }})
{{ formatMessage(messages.feeBreakdownAmount) }}
- {{ formatMoney(amount || 0) }}
+ {{ formatMoney(amountInUsd) }}
@@ -29,7 +29,7 @@
- -{{ formatMoney(fee || 0) }}
+ -{{ formatMoney(feeInUsd) }}
@@ -79,9 +79,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 +110,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 +131,21 @@ 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..7835c7cdb4 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,14 @@
- {{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
+ {{
+ formatMoney(
+ selectedMethodCurrencyCode &&
+ selectedMethodCurrencyCode !== 'USD' &&
+ selectedMethodExchangeRate
+ ? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
+ : (fixedDenominationMin ?? effectiveMinAmount),
+ )
}}
({{
formatAmountForDisplay(
@@ -103,9 +110,15 @@
min,
{{
formatMoney(
- fixedDenominationMax ??
- selectedMethodDetails.interval?.standard?.max ??
- effectiveMaxAmount,
+ selectedMethodCurrencyCode &&
+ selectedMethodCurrencyCode !== 'USD' &&
+ selectedMethodExchangeRate
+ ? (fixedDenominationMax ??
+ selectedMethodDetails.interval?.standard?.max ??
+ effectiveMaxAmount) / selectedMethodExchangeRate
+ : (fixedDenominationMax ??
+ selectedMethodDetails.interval?.standard?.max ??
+ effectiveMaxAmount),
)
}}
({{
@@ -124,7 +137,15 @@
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
- You need at least {{ formatMoney(effectiveMinAmount)
+ You need at least
+ {{
+ formatMoney(
+ selectedMethodCurrencyCode &&
+ selectedMethodCurrencyCode !== 'USD' &&
+ selectedMethodExchangeRate
+ ? effectiveMinAmount / selectedMethodExchangeRate
+ : effectiveMinAmount,
+ )
}}
({{
formatAmountForDisplay(
@@ -186,7 +207,7 @@
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
- roundedMaxAmount,
+ roundedMaxAmount * selectedMethodExchangeRate,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
@@ -252,7 +273,7 @@
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
- roundedMaxAmount,
+ roundedMaxAmount * selectedMethodExchangeRate,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
@@ -573,14 +594,13 @@ const giftCardExchangeRate = computed(() => {
})
function formatAmountForDisplay(
- usdAmount: number,
+ localAmount: number,
currencyCode: string | null | undefined,
rate: number | null | undefined,
): string {
if (!currencyCode || currencyCode === 'USD' || !rate) {
- return formatMoney(usdAmount)
+ return formatMoney(localAmount)
}
- const localAmount = usdAmount * rate
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
diff --git a/apps/frontend/src/providers/creator-withdraw.ts b/apps/frontend/src/providers/creator-withdraw.ts
index 65b62a3c38..0f16842bc0 100644
--- a/apps/frontend/src/providers/creator-withdraw.ts
+++ b/apps/frontend/src/providers/creator-withdraw.ts
@@ -40,11 +40,6 @@ export interface PayoutMethod {
category?: string
image_url: string | null
image_logo_url: string | null
- fee: {
- percentage: number
- min: number
- max: number | null
- }
interval: {
standard: {
min: number
@@ -130,6 +125,7 @@ export interface TaxData {
export interface CalculationData {
amount: number
fee: number | null
+ netUsd: number | null
exchangeRate: number | null
}
@@ -400,6 +396,7 @@ export function createWithdrawContext(
calculation: {
amount: 0,
fee: null,
+ netUsd: null,
exchangeRate: null,
},
providerData: {
@@ -841,14 +838,20 @@ export function createWithdrawContext(
apiVersion: 3,
method: 'POST',
body: payload,
- })) as { fee: number | string | null; exchange_rate: number | string | null }
+ })) as {
+ net_usd: number | string | null
+ fee: number | string | null
+ exchange_rate: number | string | null
+ }
const parsedFee = response.fee ? Number.parseFloat(String(response.fee)) : 0
+ const parsedNetUsd = response.net_usd ? Number.parseFloat(String(response.net_usd)) : null
const parsedExchangeRate = response.exchange_rate
? Number.parseFloat(String(response.exchange_rate))
: null
withdrawData.value.calculation.fee = parsedFee
+ withdrawData.value.calculation.netUsd = parsedNetUsd
withdrawData.value.calculation.exchangeRate = parsedExchangeRate
return {
@@ -872,7 +875,9 @@ export function createWithdrawContext(
created: new Date(),
amount: withdrawData.value.calculation.amount,
fee: withdrawData.value.calculation.fee || 0,
- netAmount: withdrawData.value.calculation.amount - (withdrawData.value.calculation.fee || 0),
+ netAmount:
+ withdrawData.value.calculation.netUsd ??
+ withdrawData.value.calculation.amount - (withdrawData.value.calculation.fee || 0),
methodType: getMethodDisplayName(withdrawData.value.selection.method),
recipientDisplay: getRecipientDisplay(withdrawData.value),
}
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/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs
index c431ca7518..574dde676d 100644
--- a/apps/labrinth/src/models/v3/payouts.rs
+++ b/apps/labrinth/src/models/v3/payouts.rs
@@ -49,6 +49,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(
@@ -238,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
new file mode 100644
index 0000000000..44555cd3c1
--- /dev/null
+++ b/apps/labrinth/src/queue/payouts/flow/mod.rs
@@ -0,0 +1,174 @@
+//! 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::PgTransaction;
+use thiserror::Error;
+
+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 {
+ /// 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, 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,
+ &get_method.await?,
+ )
+ .await
+ }
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct PayoutFlow {
+ /// 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),
+ Tremendous(tremendous::TremendousFlow),
+}
+
+struct ExecuteContext<'a> {
+ queue: &'a PayoutsQueue,
+ user: &'a DBUser,
+ 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,
+ payout_id: DBPayoutId,
+ transaction: PgTransaction<'_>,
+ gotenberg: &GotenbergClient,
+ ) -> Result<(), ApiError> {
+ let cx = ExecuteContext {
+ queue,
+ user,
+ 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..86cb2afc32
--- /dev/null
+++ b/apps/labrinth/src/queue/payouts/flow/mural.rs
@@ -0,0 +1,309 @@
+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, PayoutMethodFee, PayoutMethodType, PayoutStatus,
+ },
+ queue::payouts::{
+ PayoutsQueue,
+ flow::{
+ ExecuteContext, PayoutFlow, PayoutFlowInner, get_verified_email,
+ },
+ mural::MuralPayoutRequest,
+ },
+ routes::ApiError,
+ util::error::Context,
+};
+
+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 {
+ 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 = 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;
+ 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,
+ ..
+ } => {
+ 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,
+ ..
+ } => {
+ method_fee_usd = Decimal2dp::rounded(
+ fee_total.token_amount,
+ RoundingStrategy::AwayFromZero,
+ );
+ 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}"
+ )));
+ }
+ }
+ }
+ };
+
+ let total_fee_usd = method_fee_usd + platform_fee_usd;
+ let net_usd = gross_usd - total_fee_usd;
+
+ Ok(PayoutFlow {
+ net_usd,
+ total_fee_usd,
+ min_amount_usd,
+ max_amount_usd: MAX_USD,
+ 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,
+ 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 {
+ mural
+ .client
+ .execute_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..c8e20e279a
--- /dev/null
+++ b/apps/labrinth/src/queue/payouts/flow/paypal.rs
@@ -0,0 +1,233 @@
+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)),
+};
+
+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,
+ net_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 net_usd = gross_usd - fee_usd;
+
+ Ok(PayoutFlow {
+ 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,
+ net_usd,
+ fee_usd,
+ }),
+ })
+}
+
+pub(super) async fn execute(
+ ExecuteContext {
+ queue,
+ user,
+ payout_id,
+ mut transaction,
+ gotenberg: _,
+ }: ExecuteContext<'_>,
+ PayPalFlow {
+ is_venmo,
+ net_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": net_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: net_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..c5b2cc1ab3
--- /dev/null
+++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs
@@ -0,0 +1,245 @@
+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 {
+ 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 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 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:
+ //
+ // 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,
+ }),
+ })
+ }
+ _ => {
+ 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,
+ 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,
+ }),
+ })
+ }
+ }
+}
+
+pub(super) async fn execute(
+ ExecuteContext {
+ queue,
+ user,
+ payout_id,
+ mut transaction,
+ gotenberg: _,
+ }: ExecuteContext<'_>,
+ 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 b68bcdb057..1402df6a80 100644
--- a/apps/labrinth/src/queue/payouts/mod.rs
+++ b/apps/labrinth/src/queue/payouts/mod.rs
@@ -2,12 +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::mural::MuralPayoutRequest;
use crate::routes::ApiError;
use crate::util::env::env_var;
use crate::util::error::Context;
@@ -18,12 +16,13 @@ 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 muralpay::FiatAndRailCode;
use reqwest::Method;
+use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
-use rust_decimal::{Decimal, RoundingStrategy, dec};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -33,9 +32,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,
};
@@ -102,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",
@@ -122,44 +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.
- 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: Decimal::from(10_000),
- },
- fee: PayoutMethodFee {
- percentage: Decimal::from(1) / Decimal::from(100),
- min: Decimal::ZERO,
- max: Some(Decimal::ZERO),
+ currency_code: None,
+ exchange_rate: None,
},
- currency_code: None,
- exchange_rate: None,
- })
+ )
.collect()
}
@@ -444,13 +446,8 @@ impl PayoutsQueue {
image_url: None,
image_logo_url: None,
interval: PayoutInterval::Standard {
- 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)),
+ min: flow::paypal::MIN_USD.get(),
+ max: flow::paypal::MAX_USD.get(),
},
currency_code: None,
exchange_rate: None,
@@ -622,133 +619,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: fee,
- platform_fee: Decimal2dp::ZERO,
- 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)]
@@ -889,30 +759,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;
@@ -921,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,
@@ -947,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 {
@@ -963,7 +808,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/queue/payouts/mural.rs b/apps/labrinth/src/queue/payouts/mural.rs
index d90d8c7f44..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, TokenFeeRequest};
use rust_decimal::{Decimal, prelude::ToPrimitive};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -12,8 +10,8 @@ use tracing::{info, trace, warn};
use crate::{
database::models::DBPayoutId,
models::payouts::{PayoutMethodType, PayoutStatus},
- queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue},
- routes::ApiError,
+ queue::payouts::{AccountBalance, PayoutsQueue},
+ routes::{ApiError, internal::gotenberg::GotenbergDocument},
util::{
error::Context,
gotenberg::{GotenbergClient, PaymentStatement},
@@ -34,83 +32,21 @@ pub enum MuralPayoutRequest {
}
impl PayoutsQueue {
- pub async fn compute_muralpay_fees(
- &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")?;
-
- 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,
- }])
- .await
- .wrap_internal_err("failed to request fees")?;
- let fee = fees
- .into_iter()
- .next()
- .wrap_internal_err("no fees returned")?;
- Ok(fee)
- }
-
- pub async fn create_muralpay_payout_request(
+ pub async fn create_mural_payment_statement_doc(
&self,
payout_id: DBPayoutId,
- user_id: UserId,
- gross_amount: Decimal2dp,
- fees: PayoutFees,
- payout_details: MuralPayoutRequest,
- recipient_info: muralpay::CreatePayoutRecipientInfo,
+ net_usd: Decimal2dp,
+ total_fee_usd: Decimal2dp,
+ 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();
+ ) -> Result {
+ let gross_usd = net_usd + total_fee_usd;
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 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,
@@ -125,12 +61,12 @@ impl PayoutsQueue {
recipient_address_line_3: Some(address_line_3),
recipient_email,
payment_date: Utc::now(),
- gross_amount_cents: gross_amount_cents
+ gross_amount_cents: gross_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
+ .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_amount_cents}` cannot be expressed as an `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`"))?,
@@ -152,55 +88,7 @@ impl PayoutsQueue {
// )
// .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(())
+ Ok(payment_statement_doc)
}
pub async fn cancel_mural_payout_request(
diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs
index 3c83d87bf3..0ed13f2c63 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,
- 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,14 @@ 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,16 +415,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 net_usd: Decimal2dp,
pub fee: Decimal2dp,
pub exchange_rate: Option,
}
@@ -459,13 +445,12 @@ 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,
+ net_usd: payout_flow.net_usd,
+ fee: payout_flow.total_fee_usd,
+ exchange_rate: payout_flow.forex_usd_to_currency,
}))
}
@@ -581,63 +566,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,425 +587,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,
- 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 (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 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
}
diff --git a/scripts/i18n-import-check.ts b/scripts/i18n-import-check.ts
index 4e29eb5a5a..c66f761bf9 100644
--- a/scripts/i18n-import-check.ts
+++ b/scripts/i18n-import-check.ts
@@ -298,7 +298,7 @@ function analyzeFile(filePath: string): FileIssue[] {
}
}
} catch {
- // Silent fail for unparseable files
+ // Silent fail for unparsable files
}
return issues