You've already forked AstralRinth
forked from didirus/AstralRinth
d055dc68dc
* 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>
175 lines
5.1 KiB
Rust
175 lines
5.1 KiB
Rust
//! 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)
|
|
}
|