forked from didirus/AstralRinth
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
26
apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json
generated
Normal file
26
apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
20
apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json
generated
Normal file
20
apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json
generated
Normal 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"
|
||||
}
|
||||
18
apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json
generated
Normal file
18
apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json
generated
Normal 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"
|
||||
}
|
||||
17
apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json
generated
Normal file
17
apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
174
apps/labrinth/src/queue/payouts/flow/mod.rs
Normal file
174
apps/labrinth/src/queue/payouts/flow/mod.rs
Normal 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)
|
||||
}
|
||||
309
apps/labrinth/src/queue/payouts/flow/mural.rs
Normal file
309
apps/labrinth/src/queue/payouts/flow/mural.rs
Normal 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(())
|
||||
}
|
||||
233
apps/labrinth/src/queue/payouts/flow/paypal.rs
Normal file
233
apps/labrinth/src/queue/payouts/flow/paypal.rs
Normal 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(())
|
||||
}
|
||||
245
apps/labrinth/src/queue/payouts/flow/tremendous.rs
Normal file
245
apps/labrinth/src/queue/payouts/flow/tremendous.rs
Normal 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(¤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<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(())
|
||||
}
|
||||
@@ -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(¤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::<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),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(¤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<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")]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user