1
0

Payout flows in backend - fix Tremendous forex cards (#5001)

* wip: payouts flow api

* working

* Finish up flow migration

* vibe-coded frontend changes

* fix typos and vue

* fix: types

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
aecsocket
2026-01-14 10:53:35 +00:00
committed by GitHub
parent 50a87ba933
commit d055dc68dc
17 changed files with 1224 additions and 873 deletions

View File

@@ -12,14 +12,14 @@
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
<span class="font-semibold text-contrast"
>{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }})</span
>{{ formatMoney(amountInUsd) }} ({{ formattedLocalCurrencyAmount }})</span
>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amountInUsd) }}</span>
</div>
</template>
@@ -29,7 +29,7 @@
<template v-if="feeLoading">
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
</template>
<template v-else>-{{ formatMoney(fee || 0) }}</template>
<template v-else>-{{ formatMoney(feeInUsd) }}</template>
</span>
</div>
@@ -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',

View File

@@ -90,7 +90,14 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
: (fixedDenominationMin ?? effectiveMinAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
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),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
@@ -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,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
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',

View File

@@ -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),
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<String>,
pub image_logo_url: Option<String>,
pub interval: PayoutInterval,
pub fee: PayoutMethodFee,
pub currency_code: Option<String>,
/// USD to the given `currency_code`.
#[serde(with = "rust_decimal::serde::float_option")]
pub exchange_rate: Option<Decimal>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PayoutMethodFee {
#[serde(with = "rust_decimal::serde::float")]
pub percentage: Decimal,

View File

@@ -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<PayoutFlow, ApiError> {
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<Decimal>,
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<ReadyPayoutFlow, ValidateError> {
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)
}

View File

@@ -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<PayoutFlow, ApiError> {
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(())
}

View File

@@ -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<PayoutFlow, ApiError> {
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<PayPalLink>,
}
#[derive(Deserialize)]
struct PayoutItem {
pub payout_item_id: String,
}
#[derive(Deserialize)]
struct PayoutData {
pub items: Vec<PayoutItem>,
}
// 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(())
}

View File

@@ -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<PayoutFlow, ApiError> {
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(&currency_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(&currency_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<Reward>,
}
#[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(())
}

View File

@@ -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<PayoutMethod> {
.collect::<Vec<_>>();
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<PayoutMethod> {
"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),
currency_code: None,
exchange_rate: None,
},
fee: PayoutMethodFee {
percentage: Decimal::from(1) / Decimal::from(100),
min: Decimal::ZERO,
max: Some(Decimal::ZERO),
},
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<PayoutFees, ApiError> {
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(&currency_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::<Vec<_>>();
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),
};

View File

@@ -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<muralpay::TokenPayoutFee, ApiError> {
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<muralpay::PayoutRequest, ApiError> {
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<GotenbergDocument, ApiError> {
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(

View File

@@ -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<Decimal>,
}
@@ -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<Reward>,
}
#[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(&currency_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<PayPalLink>,
}
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<PayoutItem>,
}
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")]

View File

@@ -48,6 +48,10 @@ impl<const DP: u32> DecimalDp<DP> {
}
}
pub const fn new_unchecked(v: Decimal) -> Self {
Self(v)
}
pub fn get(self) -> Decimal {
self.0
}