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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user