You've already forked AstralRinth
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">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
|
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
|
||||||
<span class="font-semibold text-contrast"
|
<span class="font-semibold text-contrast"
|
||||||
>{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }})</span
|
>{{ formatMoney(amountInUsd) }} ({{ formattedLocalCurrencyAmount }})</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<template v-if="feeLoading">
|
<template v-if="feeLoading">
|
||||||
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
|
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>-{{ formatMoney(fee || 0) }}</template>
|
<template v-else>-{{ formatMoney(feeInUsd) }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,9 +79,23 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
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 netAmount = computed(() => {
|
||||||
const amount = props.amount || 0
|
const amount = amountInUsd.value
|
||||||
const fee = props.fee || 0
|
const fee = feeInUsd.value
|
||||||
return Math.max(0, amount - fee)
|
return Math.max(0, amount - fee)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,6 +110,11 @@ const netAmountInLocalCurrency = computed(() => {
|
|||||||
return netAmount.value * (props.exchangeRate || 0)
|
return netAmount.value * (props.exchangeRate || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const localCurrencyAmount = computed(() => {
|
||||||
|
if (!shouldShowExchangeRate.value) return null
|
||||||
|
return props.amount || 0
|
||||||
|
})
|
||||||
|
|
||||||
const formattedLocalCurrency = computed(() => {
|
const formattedLocalCurrency = computed(() => {
|
||||||
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
|
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
|
||||||
return ''
|
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({
|
const messages = defineMessages({
|
||||||
feeBreakdownAmount: {
|
feeBreakdownAmount: {
|
||||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
|
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
|
||||||
|
|||||||
@@ -90,7 +90,14 @@
|
|||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedMethodDetails" class="text-secondary">
|
<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'">
|
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||||
({{
|
({{
|
||||||
formatAmountForDisplay(
|
formatAmountForDisplay(
|
||||||
@@ -103,9 +110,15 @@
|
|||||||
min,
|
min,
|
||||||
{{
|
{{
|
||||||
formatMoney(
|
formatMoney(
|
||||||
fixedDenominationMax ??
|
selectedMethodCurrencyCode &&
|
||||||
selectedMethodDetails.interval?.standard?.max ??
|
selectedMethodCurrencyCode !== 'USD' &&
|
||||||
effectiveMaxAmount,
|
selectedMethodExchangeRate
|
||||||
|
? (fixedDenominationMax ??
|
||||||
|
selectedMethodDetails.interval?.standard?.max ??
|
||||||
|
effectiveMaxAmount) / selectedMethodExchangeRate
|
||||||
|
: (fixedDenominationMax ??
|
||||||
|
selectedMethodDetails.interval?.standard?.max ??
|
||||||
|
effectiveMaxAmount),
|
||||||
)
|
)
|
||||||
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||||
({{
|
({{
|
||||||
@@ -124,7 +137,15 @@
|
|||||||
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
|
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
|
||||||
class="text-sm text-red"
|
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'">
|
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||||
({{
|
({{
|
||||||
formatAmountForDisplay(
|
formatAmountForDisplay(
|
||||||
@@ -186,7 +207,7 @@
|
|||||||
formatMessage(messages.balanceWorthHint, {
|
formatMessage(messages.balanceWorthHint, {
|
||||||
usdBalance: formatMoney(roundedMaxAmount),
|
usdBalance: formatMoney(roundedMaxAmount),
|
||||||
localBalance: formatAmountForDisplay(
|
localBalance: formatAmountForDisplay(
|
||||||
roundedMaxAmount,
|
roundedMaxAmount * selectedMethodExchangeRate,
|
||||||
selectedMethodCurrencyCode,
|
selectedMethodCurrencyCode,
|
||||||
selectedMethodExchangeRate,
|
selectedMethodExchangeRate,
|
||||||
),
|
),
|
||||||
@@ -252,7 +273,7 @@
|
|||||||
formatMessage(messages.balanceWorthHint, {
|
formatMessage(messages.balanceWorthHint, {
|
||||||
usdBalance: formatMoney(roundedMaxAmount),
|
usdBalance: formatMoney(roundedMaxAmount),
|
||||||
localBalance: formatAmountForDisplay(
|
localBalance: formatAmountForDisplay(
|
||||||
roundedMaxAmount,
|
roundedMaxAmount * selectedMethodExchangeRate,
|
||||||
selectedMethodCurrencyCode,
|
selectedMethodCurrencyCode,
|
||||||
selectedMethodExchangeRate,
|
selectedMethodExchangeRate,
|
||||||
),
|
),
|
||||||
@@ -573,14 +594,13 @@ const giftCardExchangeRate = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function formatAmountForDisplay(
|
function formatAmountForDisplay(
|
||||||
usdAmount: number,
|
localAmount: number,
|
||||||
currencyCode: string | null | undefined,
|
currencyCode: string | null | undefined,
|
||||||
rate: number | null | undefined,
|
rate: number | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (!currencyCode || currencyCode === 'USD' || !rate) {
|
if (!currencyCode || currencyCode === 'USD' || !rate) {
|
||||||
return formatMoney(usdAmount)
|
return formatMoney(localAmount)
|
||||||
}
|
}
|
||||||
const localAmount = usdAmount * rate
|
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ export interface PayoutMethod {
|
|||||||
category?: string
|
category?: string
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
image_logo_url: string | null
|
image_logo_url: string | null
|
||||||
fee: {
|
|
||||||
percentage: number
|
|
||||||
min: number
|
|
||||||
max: number | null
|
|
||||||
}
|
|
||||||
interval: {
|
interval: {
|
||||||
standard: {
|
standard: {
|
||||||
min: number
|
min: number
|
||||||
@@ -130,6 +125,7 @@ export interface TaxData {
|
|||||||
export interface CalculationData {
|
export interface CalculationData {
|
||||||
amount: number
|
amount: number
|
||||||
fee: number | null
|
fee: number | null
|
||||||
|
netUsd: number | null
|
||||||
exchangeRate: number | null
|
exchangeRate: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +396,7 @@ export function createWithdrawContext(
|
|||||||
calculation: {
|
calculation: {
|
||||||
amount: 0,
|
amount: 0,
|
||||||
fee: null,
|
fee: null,
|
||||||
|
netUsd: null,
|
||||||
exchangeRate: null,
|
exchangeRate: null,
|
||||||
},
|
},
|
||||||
providerData: {
|
providerData: {
|
||||||
@@ -841,14 +838,20 @@ export function createWithdrawContext(
|
|||||||
apiVersion: 3,
|
apiVersion: 3,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: payload,
|
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 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
|
const parsedExchangeRate = response.exchange_rate
|
||||||
? Number.parseFloat(String(response.exchange_rate))
|
? Number.parseFloat(String(response.exchange_rate))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
withdrawData.value.calculation.fee = parsedFee
|
withdrawData.value.calculation.fee = parsedFee
|
||||||
|
withdrawData.value.calculation.netUsd = parsedNetUsd
|
||||||
withdrawData.value.calculation.exchangeRate = parsedExchangeRate
|
withdrawData.value.calculation.exchangeRate = parsedExchangeRate
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -872,7 +875,9 @@ export function createWithdrawContext(
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
amount: withdrawData.value.calculation.amount,
|
amount: withdrawData.value.calculation.amount,
|
||||||
fee: withdrawData.value.calculation.fee || 0,
|
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),
|
methodType: getMethodDisplayName(withdrawData.value.selection.method),
|
||||||
recipientDisplay: getRecipientDisplay(withdrawData.value),
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(tag = "method", rename_all = "lowercase")]
|
#[serde(tag = "method", rename_all = "lowercase")]
|
||||||
#[expect(
|
#[expect(
|
||||||
@@ -238,14 +246,13 @@ pub struct PayoutMethod {
|
|||||||
pub image_url: Option<String>,
|
pub image_url: Option<String>,
|
||||||
pub image_logo_url: Option<String>,
|
pub image_logo_url: Option<String>,
|
||||||
pub interval: PayoutInterval,
|
pub interval: PayoutInterval,
|
||||||
pub fee: PayoutMethodFee,
|
|
||||||
pub currency_code: Option<String>,
|
pub currency_code: Option<String>,
|
||||||
/// USD to the given `currency_code`.
|
/// USD to the given `currency_code`.
|
||||||
#[serde(with = "rust_decimal::serde::float_option")]
|
#[serde(with = "rust_decimal::serde::float_option")]
|
||||||
pub exchange_rate: Option<Decimal>,
|
pub exchange_rate: Option<Decimal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
pub struct PayoutMethodFee {
|
pub struct PayoutMethodFee {
|
||||||
#[serde(with = "rust_decimal::serde::float")]
|
#[serde(with = "rust_decimal::serde::float")]
|
||||||
pub percentage: Decimal,
|
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::models::payouts_values_notifications;
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::payouts::{
|
use crate::models::payouts::{
|
||||||
MuralPayDetails, PayoutDecimal, PayoutInterval, PayoutMethod,
|
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodType,
|
||||||
PayoutMethodFee, PayoutMethodRequest, PayoutMethodType,
|
|
||||||
TremendousForexResponse,
|
TremendousForexResponse,
|
||||||
};
|
};
|
||||||
use crate::models::projects::MonetizationStatus;
|
use crate::models::projects::MonetizationStatus;
|
||||||
use crate::queue::payouts::mural::MuralPayoutRequest;
|
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::util::env::env_var;
|
use crate::util::env::env_var;
|
||||||
use crate::util::error::Context;
|
use crate::util::error::Context;
|
||||||
@@ -18,12 +16,13 @@ use arc_swap::ArcSwapOption;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc};
|
use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use eyre::{Result, eyre};
|
use eyre::Result;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use modrinth_util::decimal::Decimal2dp;
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
|
use muralpay::FiatAndRailCode;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
use rust_decimal::prelude::ToPrimitive;
|
use rust_decimal::prelude::ToPrimitive;
|
||||||
use rust_decimal::{Decimal, RoundingStrategy, dec};
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -33,9 +32,9 @@ use std::collections::HashMap;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
pub mod mural;
|
|
||||||
|
|
||||||
mod affiliate;
|
mod affiliate;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod mural;
|
||||||
pub use affiliate::{
|
pub use affiliate::{
|
||||||
process_affiliate_payouts, remove_payouts_for_refunded_charges,
|
process_affiliate_payouts, remove_payouts_for_refunded_charges,
|
||||||
};
|
};
|
||||||
@@ -102,18 +101,28 @@ fn create_muralpay_methods() -> Vec<PayoutMethod> {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let currencies = vec![
|
let currencies = vec![
|
||||||
("blockchain_usdc_polygon", "USDC on Polygon", all_countries),
|
(
|
||||||
("fiat_mxn", "MXN", vec!["MX"]),
|
"blockchain_usdc_polygon",
|
||||||
("fiat_brl", "BRL", vec!["BR"]),
|
"USDC on Polygon",
|
||||||
("fiat_clp", "CLP", vec!["CL"]),
|
all_countries,
|
||||||
("fiat_crc", "CRC", vec!["CR"]),
|
None,
|
||||||
("fiat_pen", "PEN", vec!["PE"]),
|
),
|
||||||
|
("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_dop", "DOP"), // unsupported in API
|
||||||
// ("fiat_uyu", "UYU"), // unsupported in API
|
// ("fiat_uyu", "UYU"), // unsupported in API
|
||||||
("fiat_ars", "ARS", vec!["AR"]),
|
("fiat_ars", "ARS", vec!["AR"], Some(FiatAndRailCode::Ars)),
|
||||||
("fiat_cop", "COP", vec!["CO"]),
|
("fiat_cop", "COP", vec!["CO"], Some(FiatAndRailCode::Cop)),
|
||||||
("fiat_usd", "USD", vec!["US"]),
|
("fiat_usd", "USD", vec!["US"], Some(FiatAndRailCode::Usd)),
|
||||||
("fiat_usd-peru", "USD Peru", vec!["PE"]),
|
(
|
||||||
|
"fiat_usd-peru",
|
||||||
|
"USD Peru",
|
||||||
|
vec!["PE"],
|
||||||
|
Some(FiatAndRailCode::UsdPeru),
|
||||||
|
),
|
||||||
// ("fiat_usd-panama", "USD Panama"), // by request
|
// ("fiat_usd-panama", "USD Panama"), // by request
|
||||||
(
|
(
|
||||||
"fiat_eur",
|
"fiat_eur",
|
||||||
@@ -122,44 +131,37 @@ fn create_muralpay_methods() -> Vec<PayoutMethod> {
|
|||||||
"DE", "FR", "IT", "ES", "NL", "BE", "AT", "PT", "FI", "IE",
|
"DE", "FR", "IT", "ES", "NL", "BE", "AT", "PT", "FI", "IE",
|
||||||
"GR", "LU", "CY", "MT", "SK", "SI", "EE", "LV", "LT",
|
"GR", "LU", "CY", "MT", "SK", "SI", "EE", "LV", "LT",
|
||||||
],
|
],
|
||||||
|
Some(FiatAndRailCode::Eur),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
currencies
|
currencies
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, currency, countries)| PayoutMethod {
|
.map(
|
||||||
id: id.to_string(),
|
|(id, currency, countries, fiat_and_rail_code)| PayoutMethod {
|
||||||
type_: PayoutMethodType::MuralPay,
|
id: id.to_string(),
|
||||||
name: format!("Mural Pay - {currency}"),
|
type_: PayoutMethodType::MuralPay,
|
||||||
category: None,
|
name: format!("Mural Pay - {currency}"),
|
||||||
supported_countries: countries
|
category: None,
|
||||||
.iter()
|
supported_countries: countries
|
||||||
.map(|s| s.to_string())
|
.iter()
|
||||||
.collect(),
|
.map(|s| s.to_string())
|
||||||
image_url: None,
|
.collect(),
|
||||||
image_logo_url: None,
|
image_url: None,
|
||||||
interval: PayoutInterval::Standard {
|
image_logo_url: None,
|
||||||
// Different countries and currencies supported by Mural have different fees.
|
interval: PayoutInterval::Standard {
|
||||||
min: match id {
|
min: if let Some(fiat_and_rail_code) = fiat_and_rail_code {
|
||||||
// Due to relatively low volume of Peru withdrawals, fees are higher,
|
flow::mural::min_usd_fiat(fiat_and_rail_code)
|
||||||
// so we need to raise the minimum to cover these fees.
|
} else {
|
||||||
"fiat_usd-peru" => Decimal::from(10),
|
flow::mural::MIN_USD_BLOCKCHAIN
|
||||||
// USDC has much lower fees.
|
|
||||||
"blockchain_usdc_polygon" => {
|
|
||||||
Decimal::from(10) / Decimal::from(100)
|
|
||||||
}
|
}
|
||||||
_ => 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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,13 +446,8 @@ impl PayoutsQueue {
|
|||||||
image_url: None,
|
image_url: None,
|
||||||
image_logo_url: None,
|
image_logo_url: None,
|
||||||
interval: PayoutInterval::Standard {
|
interval: PayoutInterval::Standard {
|
||||||
min: Decimal::from(1) / Decimal::from(4),
|
min: flow::paypal::MIN_USD.get(),
|
||||||
max: Decimal::from(100_000),
|
max: flow::paypal::MAX_USD.get(),
|
||||||
},
|
|
||||||
fee: PayoutMethodFee {
|
|
||||||
percentage: Decimal::from(2) / Decimal::from(100),
|
|
||||||
min: Decimal::from(1) / Decimal::from(4),
|
|
||||||
max: Some(Decimal::from(1)),
|
|
||||||
},
|
},
|
||||||
currency_code: None,
|
currency_code: None,
|
||||||
exchange_rate: None,
|
exchange_rate: None,
|
||||||
@@ -622,133 +619,6 @@ impl PayoutsQueue {
|
|||||||
/ Decimal::from(100),
|
/ 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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -889,30 +759,6 @@ async fn get_tremendous_payout_methods(
|
|||||||
continue;
|
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 {
|
let Some(currency) = product.currency_codes.first() else {
|
||||||
// cards with multiple currencies are not supported
|
// cards with multiple currencies are not supported
|
||||||
continue;
|
continue;
|
||||||
@@ -921,7 +767,6 @@ async fn get_tremendous_payout_methods(
|
|||||||
warn!("No Tremendous forex data for {currency}");
|
warn!("No Tremendous forex data for {currency}");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let currency_to_usd = dec!(1) / usd_to_currency;
|
|
||||||
|
|
||||||
let method = PayoutMethod {
|
let method = PayoutMethod {
|
||||||
id: product.id,
|
id: product.id,
|
||||||
@@ -947,15 +792,15 @@ async fn get_tremendous_payout_methods(
|
|||||||
let mut values = product
|
let mut values = product
|
||||||
.skus
|
.skus
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| PayoutDecimal(x.min * currency_to_usd))
|
.map(|x| PayoutDecimal(x.min))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
values.sort_by(|a, b| a.0.cmp(&b.0));
|
values.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
PayoutInterval::Fixed { values }
|
PayoutInterval::Fixed { values }
|
||||||
} else if let Some(first) = product.skus.first() {
|
} else if let Some(first) = product.skus.first() {
|
||||||
PayoutInterval::Standard {
|
PayoutInterval::Standard {
|
||||||
min: first.min * currency_to_usd,
|
min: first.min,
|
||||||
max: first.max * currency_to_usd,
|
max: first.max,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PayoutInterval::Standard {
|
PayoutInterval::Standard {
|
||||||
@@ -963,7 +808,6 @@ async fn get_tremendous_payout_methods(
|
|||||||
max: Decimal::from(5_000),
|
max: Decimal::from(5_000),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fee,
|
|
||||||
currency_code: Some(currency.clone()),
|
currency_code: Some(currency.clone()),
|
||||||
exchange_rate: Some(usd_to_currency),
|
exchange_rate: Some(usd_to_currency),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use ariadne::ids::UserId;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use eyre::{Result, eyre};
|
use eyre::{Result, eyre};
|
||||||
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
|
use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered};
|
||||||
use modrinth_util::decimal::Decimal2dp;
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
use muralpay::{MuralError, TokenFeeRequest};
|
|
||||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -12,8 +10,8 @@ use tracing::{info, trace, warn};
|
|||||||
use crate::{
|
use crate::{
|
||||||
database::models::DBPayoutId,
|
database::models::DBPayoutId,
|
||||||
models::payouts::{PayoutMethodType, PayoutStatus},
|
models::payouts::{PayoutMethodType, PayoutStatus},
|
||||||
queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue},
|
queue::payouts::{AccountBalance, PayoutsQueue},
|
||||||
routes::ApiError,
|
routes::{ApiError, internal::gotenberg::GotenbergDocument},
|
||||||
util::{
|
util::{
|
||||||
error::Context,
|
error::Context,
|
||||||
gotenberg::{GotenbergClient, PaymentStatement},
|
gotenberg::{GotenbergClient, PaymentStatement},
|
||||||
@@ -34,83 +32,21 @@ pub enum MuralPayoutRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PayoutsQueue {
|
impl PayoutsQueue {
|
||||||
pub async fn compute_muralpay_fees(
|
pub async fn create_mural_payment_statement_doc(
|
||||||
&self,
|
|
||||||
amount: Decimal2dp,
|
|
||||||
fiat_and_rail_code: muralpay::FiatAndRailCode,
|
|
||||||
) -> Result<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(
|
|
||||||
&self,
|
&self,
|
||||||
payout_id: DBPayoutId,
|
payout_id: DBPayoutId,
|
||||||
user_id: UserId,
|
net_usd: Decimal2dp,
|
||||||
gross_amount: Decimal2dp,
|
total_fee_usd: Decimal2dp,
|
||||||
fees: PayoutFees,
|
recipient_info: &muralpay::CreatePayoutRecipientInfo,
|
||||||
payout_details: MuralPayoutRequest,
|
|
||||||
recipient_info: muralpay::CreatePayoutRecipientInfo,
|
|
||||||
gotenberg: &GotenbergClient,
|
gotenberg: &GotenbergClient,
|
||||||
) -> Result<muralpay::PayoutRequest, ApiError> {
|
) -> Result<GotenbergDocument, ApiError> {
|
||||||
let muralpay = self.muralpay.load();
|
let gross_usd = net_usd + total_fee_usd;
|
||||||
let muralpay = muralpay
|
|
||||||
.as_ref()
|
|
||||||
.wrap_internal_err("Mural Pay client not available")?;
|
|
||||||
|
|
||||||
let payout_details = match payout_details {
|
|
||||||
crate::queue::payouts::mural::MuralPayoutRequest::Fiat {
|
|
||||||
bank_name,
|
|
||||||
bank_account_owner,
|
|
||||||
fiat_and_rail_details,
|
|
||||||
} => muralpay::CreatePayoutDetails::Fiat {
|
|
||||||
bank_name,
|
|
||||||
bank_account_owner,
|
|
||||||
developer_fee: None,
|
|
||||||
fiat_and_rail_details,
|
|
||||||
},
|
|
||||||
crate::queue::payouts::mural::MuralPayoutRequest::Blockchain {
|
|
||||||
wallet_address,
|
|
||||||
} => {
|
|
||||||
muralpay::CreatePayoutDetails::Blockchain {
|
|
||||||
wallet_details: muralpay::WalletDetails {
|
|
||||||
// only Polygon chain is currently supported
|
|
||||||
blockchain: muralpay::Blockchain::Polygon,
|
|
||||||
wallet_address,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mural takes `fees.method_fee` off the top of the amount we tell them to send
|
|
||||||
let sent_to_method = gross_amount - fees.platform_fee;
|
|
||||||
// ..so the net is `gross - platform_fee - method_fee`
|
|
||||||
let net_amount = gross_amount - fees.total_fee();
|
|
||||||
|
|
||||||
let recipient_address = recipient_info.physical_address();
|
let recipient_address = recipient_info.physical_address();
|
||||||
let recipient_email = recipient_info.email().to_string();
|
let recipient_email = recipient_info.email().to_string();
|
||||||
let gross_amount_cents = gross_amount.get() * Decimal::from(100);
|
let gross_cents = gross_usd.get() * Decimal::from(100);
|
||||||
let net_amount_cents = net_amount.get() * Decimal::from(100);
|
let net_cents = net_usd.get() * Decimal::from(100);
|
||||||
let fees_cents = fees.total_fee().get() * Decimal::from(100);
|
let fees_cents = total_fee_usd.get() * Decimal::from(100);
|
||||||
let address_line_3 = format!(
|
let address_line_3 = format!(
|
||||||
"{}, {}, {}",
|
"{}, {}, {}",
|
||||||
recipient_address.city,
|
recipient_address.city,
|
||||||
@@ -125,12 +61,12 @@ impl PayoutsQueue {
|
|||||||
recipient_address_line_3: Some(address_line_3),
|
recipient_address_line_3: Some(address_line_3),
|
||||||
recipient_email,
|
recipient_email,
|
||||||
payment_date: Utc::now(),
|
payment_date: Utc::now(),
|
||||||
gross_amount_cents: gross_amount_cents
|
gross_amount_cents: gross_cents
|
||||||
.to_i64()
|
.to_i64()
|
||||||
.wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_amount_cents}` cannot be expressed as an `i64`"))?,
|
.wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_cents}` cannot be expressed as an `i64`"))?,
|
||||||
net_amount_cents: net_amount_cents
|
net_amount_cents: net_cents
|
||||||
.to_i64()
|
.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
|
fees_cents: fees_cents
|
||||||
.to_i64()
|
.to_i64()
|
||||||
.wrap_internal_err_with(|| eyre!("fees amount of cents `{fees_cents}` cannot be expressed as an `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();
|
// .unwrap();
|
||||||
|
|
||||||
let payout = muralpay::CreatePayout {
|
Ok(payment_statement_doc)
|
||||||
amount: muralpay::TokenAmount {
|
|
||||||
token_amount: sent_to_method.get(),
|
|
||||||
token_symbol: muralpay::USDC.into(),
|
|
||||||
},
|
|
||||||
payout_details,
|
|
||||||
recipient_info,
|
|
||||||
supporting_details: Some(muralpay::SupportingDetails {
|
|
||||||
supporting_document: Some(format!(
|
|
||||||
"data:application/pdf;base64,{}",
|
|
||||||
payment_statement_doc.body
|
|
||||||
)),
|
|
||||||
payout_purpose: Some(muralpay::PayoutPurpose::VendorPayment),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let payout_request = muralpay
|
|
||||||
.client
|
|
||||||
.create_payout_request(
|
|
||||||
muralpay.source_account_id,
|
|
||||||
Some(format!("User {user_id}")),
|
|
||||||
&[payout],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| match err {
|
|
||||||
MuralError::Api(err) => ApiError::Mural(Box::new(err)),
|
|
||||||
err => ApiError::Internal(
|
|
||||||
eyre!(err).wrap_err("failed to create payout request"),
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(payout_request)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute_mural_payout_request(
|
|
||||||
&self,
|
|
||||||
id: muralpay::PayoutRequestId,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let muralpay = self.muralpay.load();
|
|
||||||
let muralpay = muralpay
|
|
||||||
.as_ref()
|
|
||||||
.wrap_internal_err("Mural Pay client not available")?;
|
|
||||||
|
|
||||||
muralpay
|
|
||||||
.client
|
|
||||||
.execute_payout_request(id)
|
|
||||||
.await
|
|
||||||
.wrap_internal_err("failed to execute payout request")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cancel_mural_payout_request(
|
pub async fn cancel_mural_payout_request(
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
use crate::auth::{AuthenticationError, get_user_from_headers};
|
use crate::auth::{AuthenticationError, get_user_from_headers};
|
||||||
use crate::database::models::payout_item::DBPayout;
|
use crate::database::models::DBUserId;
|
||||||
use crate::database::models::{DBPayoutId, DBUser, DBUserId};
|
|
||||||
use crate::database::models::{generate_payout_id, users_compliance};
|
use crate::database::models::{generate_payout_id, users_compliance};
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::ids::PayoutId;
|
use crate::models::ids::PayoutId;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::payouts::{
|
use crate::models::payouts::{PayoutMethodType, PayoutStatus, Withdrawal};
|
||||||
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
use crate::queue::payouts::PayoutsQueue;
|
||||||
TremendousDetails, TremendousForexResponse,
|
|
||||||
};
|
|
||||||
use crate::queue::payouts::mural::MuralPayoutRequest;
|
|
||||||
use crate::queue::payouts::{PayoutFees, PayoutsQueue};
|
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::util::avalara1099;
|
use crate::util::avalara1099;
|
||||||
@@ -19,16 +14,14 @@ use crate::util::error::Context;
|
|||||||
use crate::util::gotenberg::GotenbergClient;
|
use crate::util::gotenberg::GotenbergClient;
|
||||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use eyre::eyre;
|
|
||||||
use hex::ToHex;
|
use hex::ToHex;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use modrinth_util::decimal::Decimal2dp;
|
use modrinth_util::decimal::Decimal2dp;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rust_decimal::{Decimal, RoundingStrategy};
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use sqlx::{PgPool, PgTransaction};
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
@@ -422,16 +415,9 @@ pub async fn tremendous_webhook(
|
|||||||
Ok(HttpResponse::NoContent().finish())
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct WithdrawalFees {
|
pub struct WithdrawalFees {
|
||||||
|
pub net_usd: Decimal2dp,
|
||||||
pub fee: Decimal2dp,
|
pub fee: Decimal2dp,
|
||||||
pub exchange_rate: Option<Decimal>,
|
pub exchange_rate: Option<Decimal>,
|
||||||
}
|
}
|
||||||
@@ -459,13 +445,12 @@ pub async fn calculate_fees(
|
|||||||
ApiError::Authentication(AuthenticationError::InvalidCredentials)
|
ApiError::Authentication(AuthenticationError::InvalidCredentials)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let fees = payouts_queue
|
let payout_flow = payouts_queue.create_payout_flow(body.0).await?;
|
||||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(web::Json(WithdrawalFees {
|
Ok(web::Json(WithdrawalFees {
|
||||||
fee: fees.total_fee(),
|
net_usd: payout_flow.net_usd,
|
||||||
exchange_rate: fees.exchange_rate,
|
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
|
let payout_flow = payouts_queue.create_payout_flow(body.0).await?;
|
||||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
let payout_flow = match payout_flow.validate(balance.available) {
|
||||||
.await
|
Ok(flow) => flow,
|
||||||
.wrap_internal_err("failed to compute fees")?;
|
Err(err) => return Err(ApiError::InvalidInput(err.to_string())),
|
||||||
|
};
|
||||||
// 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_id = generate_payout_id(&mut transaction)
|
let payout_id = generate_payout_id(&mut transaction)
|
||||||
.await
|
.await
|
||||||
.wrap_internal_err("failed to generate payout ID")?;
|
.wrap_internal_err("failed to generate payout ID")?;
|
||||||
|
|
||||||
let payout_cx = PayoutContext {
|
payout_flow
|
||||||
body: &body,
|
.execute(&payouts_queue, &user, payout_id, transaction, &gotenberg)
|
||||||
user: &user,
|
.await?;
|
||||||
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?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
|
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
|
||||||
.await
|
.await
|
||||||
@@ -646,425 +587,6 @@ pub async fn create_payout(
|
|||||||
Ok(())
|
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.
|
/// User performing a payout-related action.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[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 {
|
pub fn get(self) -> Decimal {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user