Mural Pay integration (#4520)

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* Temporarily disable Venmo and PayPal methods from frontend

* wip: counterparties

* Start on counterparties and payment methods API

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* Add fees to Mural

* Payout history route and bank details

* Re-add legacy PayPal/Venmo options for US

* move the mural bank details route

* Add utoipa support to payout endpoints

* address some PR comments

* add CORS to new utoipa routes

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* Add Mural balance to bank balance info

* Add more Tremendous currencies support

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

---------

Co-authored-by: Calum H. <contact@cal.engineer>
Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
aecsocket
2025-11-03 14:19:46 -08:00
committed by GitHub
parent b11934054d
commit 17f395ee55
34 changed files with 4381 additions and 690 deletions

View File

@@ -7,6 +7,7 @@ pub mod gdpr;
pub mod gotenberg;
pub mod medal;
pub mod moderation;
pub mod mural;
pub mod pats;
pub mod session;
pub mod statuses;
@@ -31,6 +32,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.configure(statuses::config)
.configure(medal::config)
.configure(external_notifications::config)
.configure(affiliate::config),
.configure(affiliate::config)
.configure(mural::config),
);
}

View File

@@ -0,0 +1,28 @@
use actix_web::{get, web};
use muralpay::FiatAndRailCode;
use strum::IntoEnumIterator;
use crate::{
queue::payouts::PayoutsQueue, routes::ApiError, util::error::Context,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get_bank_details);
}
#[get("/mural/bank-details")]
async fn get_bank_details(
payouts_queue: web::Data<PayoutsQueue>,
) -> Result<web::Json<muralpay::BankDetailsResponse>, ApiError> {
let mural = payouts_queue.muralpay.load();
let mural = mural
.as_ref()
.wrap_internal_err("Mural API not available")?;
let fiat_and_rail_codes = FiatAndRailCode::iter().collect::<Vec<_>>();
let details = mural
.client
.get_bank_details(&fiat_and_rail_codes)
.await
.wrap_internal_err("failed to fetch bank details")?;
Ok(web::Json(details))
}

View File

@@ -85,12 +85,18 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
);
}
/// Error when calling an HTTP endpoint.
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
/// Error occurred on the server side, which the caller has no fault in.
#[error(transparent)]
Internal(eyre::Report),
/// Caller made an invalid or malformed request.
#[error(transparent)]
Request(eyre::Report),
/// Caller attempted a request which they are not allowed to make.
#[error(transparent)]
Auth(eyre::Report),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Environment error")]
@@ -161,41 +167,47 @@ impl ApiError {
pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> {
crate::models::error::ApiError {
error: match self {
ApiError::Internal(..) => "internal_error",
Self::Internal(..) => "internal_error",
Self::Request(..) => "request_error",
ApiError::Env(..) => "environment_error",
ApiError::Database(..) => "database_error",
ApiError::SqlxDatabase(..) => "database_error",
ApiError::RedisDatabase(..) => "database_error",
ApiError::Authentication(..) => "unauthorized",
ApiError::CustomAuthentication(..) => "unauthorized",
ApiError::Xml(..) => "xml_error",
ApiError::Json(..) => "json_error",
ApiError::Search(..) => "search_error",
ApiError::Indexing(..) => "indexing_error",
ApiError::FileHosting(..) => "file_hosting_error",
ApiError::InvalidInput(..) => "invalid_input",
ApiError::Validation(..) => "invalid_input",
ApiError::Payments(..) => "payments_error",
ApiError::Discord(..) => "discord_error",
ApiError::Turnstile => "turnstile_error",
ApiError::Decoding(..) => "decoding_error",
ApiError::ImageParse(..) => "invalid_image",
ApiError::PasswordHashing(..) => "password_hashing_error",
ApiError::Mail(..) => "mail_error",
ApiError::Clickhouse(..) => "clickhouse_error",
ApiError::Reroute(..) => "reroute_error",
ApiError::NotFound => "not_found",
ApiError::Conflict(..) => "conflict",
ApiError::TaxComplianceApi => "tax_compliance_api_error",
ApiError::Zip(..) => "zip_error",
ApiError::Io(..) => "io_error",
ApiError::RateLimitError(..) => "ratelimit_error",
ApiError::Stripe(..) => "stripe_error",
ApiError::TaxProcessor(..) => "tax_processor_error",
ApiError::Slack(..) => "slack_error",
Self::Auth(..) => "auth_error",
Self::Env(..) => "environment_error",
Self::Database(..) => "database_error",
Self::SqlxDatabase(..) => "database_error",
Self::RedisDatabase(..) => "database_error",
Self::Authentication(..) => "unauthorized",
Self::CustomAuthentication(..) => "unauthorized",
Self::Xml(..) => "xml_error",
Self::Json(..) => "json_error",
Self::Search(..) => "search_error",
Self::Indexing(..) => "indexing_error",
Self::FileHosting(..) => "file_hosting_error",
Self::InvalidInput(..) => "invalid_input",
Self::Validation(..) => "invalid_input",
Self::Payments(..) => "payments_error",
Self::Discord(..) => "discord_error",
Self::Turnstile => "turnstile_error",
Self::Decoding(..) => "decoding_error",
Self::ImageParse(..) => "invalid_image",
Self::PasswordHashing(..) => "password_hashing_error",
Self::Mail(..) => "mail_error",
Self::Clickhouse(..) => "clickhouse_error",
Self::Reroute(..) => "reroute_error",
Self::NotFound => "not_found",
Self::Conflict(..) => "conflict",
Self::TaxComplianceApi => "tax_compliance_api_error",
Self::Zip(..) => "zip_error",
Self::Io(..) => "io_error",
Self::RateLimitError(..) => "ratelimit_error",
Self::Stripe(..) => "stripe_error",
Self::TaxProcessor(..) => "tax_processor_error",
Self::Slack(..) => "slack_error",
},
description: match self {
Self::Internal(e) => format!("{e:#?}"),
Self::Request(e) => format!("{e:#?}"),
Self::Auth(e) => format!("{e:#?}"),
_ => self.to_string(),
},
description: self.to_string(),
}
}
}
@@ -203,39 +215,40 @@ impl ApiError {
impl actix_web::ResponseError for ApiError {
fn status_code(&self) -> StatusCode {
match self {
ApiError::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Request(..) => StatusCode::BAD_REQUEST,
ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST,
ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Authentication(..) => StatusCode::UNAUTHORIZED,
ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Json(..) => StatusCode::BAD_REQUEST,
ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Validation(..) => StatusCode::BAD_REQUEST,
ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY,
ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY,
ApiError::Turnstile => StatusCode::BAD_REQUEST,
ApiError::Decoding(..) => StatusCode::BAD_REQUEST,
ApiError::ImageParse(..) => StatusCode::BAD_REQUEST,
ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::Conflict(..) => StatusCode::CONFLICT,
ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
ApiError::Io(..) => StatusCode::BAD_REQUEST,
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Request(..) => StatusCode::BAD_REQUEST,
Self::Auth(..) => StatusCode::UNAUTHORIZED,
Self::InvalidInput(..) => StatusCode::BAD_REQUEST,
Self::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Authentication(..) => StatusCode::UNAUTHORIZED,
Self::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Json(..) => StatusCode::BAD_REQUEST,
Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Validation(..) => StatusCode::BAD_REQUEST,
Self::Payments(..) => StatusCode::FAILED_DEPENDENCY,
Self::Discord(..) => StatusCode::FAILED_DEPENDENCY,
Self::Turnstile => StatusCode::BAD_REQUEST,
Self::Decoding(..) => StatusCode::BAD_REQUEST,
Self::ImageParse(..) => StatusCode::BAD_REQUEST,
Self::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
Self::Conflict(..) => StatusCode::CONFLICT,
Self::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
Self::Zip(..) => StatusCode::BAD_REQUEST,
Self::Io(..) => StatusCode::BAD_REQUEST,
Self::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
Self::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
Self::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}

View File

@@ -47,7 +47,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(threads::config)
.configure(users::config)
.configure(version_file::config)
.configure(payouts::config)
.configure(versions::config)
.configure(friends::config),
);
@@ -61,6 +60,11 @@ pub fn utoipa_config(
.wrap(default_cors())
.configure(analytics_get::config),
);
cfg.service(
utoipa_actix_web::scope("/v3/payout")
.wrap(default_cors())
.configure(payouts::config),
);
}
pub async fn hello_world() -> Result<HttpResponse, ApiError> {

View File

@@ -1,11 +1,15 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::{AuthenticationError, get_user_from_headers};
use crate::database::models::DBUserId;
use crate::database::models::payout_item::DBPayout;
use crate::database::models::{DBPayoutId, DBUser, DBUserId};
use crate::database::models::{generate_payout_id, users_compliance};
use crate::database::redis::RedisPool;
use crate::models::ids::PayoutId;
use crate::models::pats::Scopes;
use crate::models::payouts::{PayoutMethodType, PayoutStatus};
use crate::models::payouts::{
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
TremendousDetails, TremendousForexResponse,
};
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
@@ -13,6 +17,7 @@ use crate::util::avalara1099;
use crate::util::error::Context;
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use chrono::{DateTime, Duration, Utc};
use eyre::eyre;
use hex::ToHex;
use hmac::{Hmac, Mac};
use reqwest::Method;
@@ -28,38 +33,26 @@ use tracing::error;
const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration =
chrono::Duration::seconds(15);
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("payout")
.service(paypal_webhook)
.service(tremendous_webhook)
// we use `route` instead of `service` because `user_payouts` uses the logic of `transaction_history`
.route(
"",
web::get().to(
#[expect(
deprecated,
reason = "v3 backwards compatibility"
)]
user_payouts,
),
)
.route("history", web::get().to(transaction_history))
.service(create_payout)
.service(cancel_payout)
.service(payment_methods)
.service(get_balance)
.service(platform_revenue)
.service(post_compliance_form),
);
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(paypal_webhook)
.service(tremendous_webhook)
.service(transaction_history)
.service(calculate_fees)
.service(create_payout)
.service(cancel_payout)
.service(payment_methods)
.service(get_balance)
.service(platform_revenue)
.service(post_compliance_form);
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RequestForm {
form_type: users_compliance::FormType,
}
#[post("compliance")]
#[utoipa::path]
#[post("/compliance")]
pub async fn post_compliance_form(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -157,7 +150,8 @@ pub async fn post_compliance_form(
}
}
#[post("_paypal")]
#[utoipa::path]
#[post("/_paypal")]
pub async fn paypal_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -314,7 +308,8 @@ pub async fn paypal_webhook(
Ok(HttpResponse::NoContent().finish())
}
#[post("_tremendous")]
#[utoipa::path]
#[post("/_tremendous")]
pub async fn tremendous_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -424,60 +419,55 @@ pub async fn tremendous_webhook(
Ok(HttpResponse::NoContent().finish())
}
#[deprecated = "use `transaction_history` instead"]
pub async fn user_payouts(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<crate::models::payouts::Payout>>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PAYOUTS_READ,
)
.await?;
let items = transaction_history(req, pool, redis, session_queue)
.await?
.0
.into_iter()
.filter_map(|txn_item| match txn_item {
TransactionItem::Withdrawal {
id,
status,
created,
amount,
fee,
method_type,
method_address,
} => Some(crate::models::payouts::Payout {
id,
user_id: user.id,
status,
created,
amount,
fee,
method: method_type,
method_address,
platform_id: None,
}),
TransactionItem::PayoutAvailable { .. } => None,
})
.collect::<Vec<_>>();
Ok(web::Json(items))
}
#[derive(Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Withdrawal {
#[serde(with = "rust_decimal::serde::float")]
amount: Decimal,
method: PayoutMethodType,
#[serde(flatten)]
method: PayoutMethodRequest,
method_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WithdrawalFees {
pub fee: Decimal,
pub exchange_rate: Option<Decimal>,
}
#[utoipa::path]
#[post("/fees")]
pub async fn calculate_fees(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
body: web::Json<Withdrawal>,
session_queue: web::Data<AuthQueue>,
payouts_queue: web::Data<PayoutsQueue>,
) -> Result<web::Json<WithdrawalFees>, ApiError> {
// even though we don't use the user, we ensure they're logged in to make API calls
let (_, _user) = get_user_record_from_bearer_token(
&req,
None,
&**pool,
&redis,
&session_queue,
)
.await?
.ok_or_else(|| {
ApiError::Authentication(AuthenticationError::InvalidCredentials)
})?;
let fees = payouts_queue
.calculate_fees(&body.method, &body.method_id, body.amount)
.await?;
Ok(web::Json(WithdrawalFees {
fee: fees.total_fee(),
exchange_rate: fees.exchange_rate,
}))
}
#[utoipa::path]
#[post("")]
pub async fn create_payout(
req: HttpRequest,
@@ -486,7 +476,7 @@ pub async fn create_payout(
body: web::Json<Withdrawal>,
session_queue: web::Data<AuthQueue>,
payouts_queue: web::Data<PayoutsQueue>,
) -> Result<HttpResponse, ApiError> {
) -> Result<(), ApiError> {
let (scopes, user) = get_user_record_from_bearer_token(
&req,
None,
@@ -514,9 +504,12 @@ pub async fn create_payout(
user.id.0
)
.fetch_optional(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to fetch user balance")?;
let balance = get_user_balance(user.id, &pool).await?;
let balance = get_user_balance(user.id, &pool)
.await
.wrap_internal_err("failed to calculate user balance")?;
if balance.available < body.amount || body.amount < Decimal::ZERO {
return Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
@@ -585,255 +578,372 @@ pub async fn create_payout(
));
}
let payout_method = payouts_queue
.get_payout_methods()
.await?
.into_iter()
.find(|x| x.id == body.method_id)
.ok_or_else(|| {
ApiError::InvalidInput(
"Invalid payment method specified!".to_string(),
)
})?;
let fees = payouts_queue
.calculate_fees(&body.method, &body.method_id, body.amount)
.await?;
let fee = std::cmp::min(
std::cmp::max(
payout_method.fee.min,
payout_method.fee.percentage * body.amount,
),
payout_method.fee.max.unwrap_or(Decimal::MAX),
);
// 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 transfer = (body.amount - fee).round_dp(2);
if transfer <= Decimal::ZERO {
if (body.amount - fees.total_fee()).round_dp(2) <= 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).await?;
let sent_to_method = (body.amount - fees.platform_fee).round_dp(2);
assert!(sent_to_method > Decimal::ZERO);
let payout_item = match body.method {
PayoutMethodType::Venmo | PayoutMethodType::PayPal => {
let (wallet, wallet_type, address, display_address) = if body.method
== PayoutMethodType::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(),
));
}
let payout_id = generate_payout_id(&mut transaction)
.await
.wrap_internal_err("failed to generate payout ID")?;
(
"PayPal",
"paypal_id",
paypal_id.clone(),
user.paypal_email.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_cx = PayoutContext {
body: &body,
user: &user,
payout_id,
raw_amount: body.amount,
total_fee: fees.total_fee(),
sent_to_method,
payouts_queue: &payouts_queue,
};
#[derive(Deserialize)]
struct PayPalLink {
href: String,
}
#[derive(Deserialize)]
struct PayoutsResponse {
pub links: Vec<PayPalLink>,
}
let mut payout_item =
crate::database::models::payout_item::DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
status: PayoutStatus::InTransit,
amount: transfer,
fee: Some(fee),
method: Some(body.method),
method_address: Some(display_address),
platform_id: None,
};
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": transfer.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?;
if let Some(link) = res.links.first() {
#[derive(Deserialize)]
struct PayoutItem {
pub payout_item_id: String,
}
#[derive(Deserialize)]
struct PayoutData {
pub items: Vec<PayoutItem>,
}
if let Ok(res) = payouts_queue
.make_paypal_request::<(), PayoutData>(
Method::GET,
&link.href,
None,
None,
Some(true),
)
.await
&& let Some(data) = res.items.first()
{
payout_item.platform_id = Some(data.payout_item_id.clone());
}
}
payout_item
let payout_item = match &body.method {
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
paypal_payout(payout_cx).await?
}
PayoutMethodType::Tremendous => {
if let Some(email) = user.email {
if user.email_verified {
let mut payout_item =
crate::database::models::payout_item::DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
status: PayoutStatus::InTransit,
amount: transfer,
fee: Some(fee),
method: Some(PayoutMethodType::Tremendous),
method_address: Some(email.clone()),
platform_id: None,
};
#[derive(Deserialize)]
struct Reward {
pub id: String,
}
#[derive(Deserialize)]
struct Order {
pub rewards: Vec<Reward>,
}
#[derive(Deserialize)]
struct TremendousResponse {
pub order: Order,
}
let res: TremendousResponse = payouts_queue
.make_tremendous_request(
Method::POST,
"orders",
Some(json! ({
"payment": {
"funding_source_id": "BALANCE",
},
"rewards": [{
"value": {
"denomination": transfer
},
"delivery": {
"method": "EMAIL"
},
"recipient": {
"name": user.username,
"email": email
},
"products": [
&body.method_id,
],
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
}]
})),
)
.await?;
if let Some(reward) = res.order.rewards.first() {
payout_item.platform_id = Some(reward.id.clone())
}
payout_item
} else {
return Err(ApiError::InvalidInput(
"You must verify your account email to proceed!"
.to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"You must add an email to your account to proceed!"
.to_string(),
));
}
PayoutMethodRequest::Tremendous { method_details } => {
tremendous_payout(payout_cx, method_details).await?
}
PayoutMethodType::Unknown => {
return Err(ApiError::Payments(
"Invalid payment method specified!".to_string(),
));
PayoutMethodRequest::MuralPay { method_details } => {
mural_pay_payout(payout_cx, method_details).await?
}
};
payout_item.insert(&mut transaction).await?;
payout_item
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert payout")?;
transaction.commit().await?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
.await?;
.await
.wrap_internal_err("failed to clear user caches")?;
Ok(HttpResponse::NoContent().finish())
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone, Copy)]
struct PayoutContext<'a> {
body: &'a Withdrawal,
user: &'a DBUser,
payout_id: DBPayoutId,
raw_amount: Decimal,
total_fee: Decimal,
sent_to_method: Decimal,
payouts_queue: &'a PayoutsQueue,
}
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,
raw_amount,
total_fee,
sent_to_method,
payouts_queue,
}: PayoutContext<'_>,
TremendousDetails {
delivery_email,
currency,
}: &TremendousDetails,
) -> Result<DBPayout, ApiError> {
let user_email = get_verified_email(user)?;
let mut payout_item = DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
status: PayoutStatus::InTransit,
amount: raw_amount,
fee: Some(total_fee),
method: Some(PayoutMethodType::Tremendous),
method_address: Some(user_email.to_string()),
platform_id: None,
};
#[derive(Deserialize)]
struct Reward {
pub id: String,
}
#[derive(Deserialize)]
struct Order {
pub rewards: Vec<Reward>,
}
#[derive(Deserialize)]
struct TremendousResponse {
pub order: Order,
}
let forex: TremendousForexResponse = payouts_queue
.make_tremendous_request(Method::GET, "forex", None::<()>)
.await
.wrap_internal_err("failed to fetch Tremendous forex data")?;
let (denomination, currency_code) = if let Some(currency) = currency {
let currency_code = currency.to_string();
let exchange_rate =
forex.forex.get(&currency_code).wrap_internal_err_with(|| {
eyre!("no Tremendous forex data for {currency}")
})?;
(sent_to_method * *exchange_rate, 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?;
if let Some(reward) = res.order.rewards.first() {
payout_item.platform_id = Some(reward.id.clone())
}
Ok(payout_item)
}
async fn mural_pay_payout(
PayoutContext {
body: _body,
user,
payout_id,
raw_amount,
total_fee,
sent_to_method,
payouts_queue,
}: PayoutContext<'_>,
details: &MuralPayDetails,
) -> Result<DBPayout, ApiError> {
let user_email = get_verified_email(user)?;
let payout_request = payouts_queue
.create_muralpay_payout_request(
user.id.into(),
muralpay::TokenAmount {
token_symbol: muralpay::USDC.into(),
token_amount: sent_to_method,
},
details.payout_details.clone(),
details.recipient_info.clone(),
)
.await?;
Ok(DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
status: PayoutStatus::Success,
amount: raw_amount,
fee: Some(total_fee),
method: Some(PayoutMethodType::MuralPay),
method_address: Some(user_email.to_string()),
platform_id: Some(payout_request.id.to_string()),
})
}
async fn paypal_payout(
PayoutContext {
body,
user,
payout_id,
raw_amount,
total_fee,
sent_to_method,
payouts_queue,
}: PayoutContext<'_>,
) -> Result<DBPayout, 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 mut payout_item = crate::database::models::payout_item::DBPayout {
id: payout_id,
user_id: user.id,
created: Utc::now(),
status: PayoutStatus::InTransit,
amount: raw_amount,
fee: Some(total_fee),
method: Some(body.method.method_type()),
method_address: Some(display_address.clone()),
platform_id: None,
};
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?;
if let Some(link) = res.links.first() {
#[derive(Deserialize)]
struct PayoutItem {
pub payout_item_id: String,
}
#[derive(Deserialize)]
struct PayoutData {
pub items: Vec<PayoutItem>,
}
if let Ok(res) = payouts_queue
.make_paypal_request::<(), PayoutData>(
Method::GET,
&link.href,
None,
None,
Some(true),
)
.await
&& let Some(data) = res.items.first()
{
payout_item.platform_id = Some(data.payout_item_id.clone());
}
}
Ok(payout_item)
}
/// User performing a payout-related action.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TransactionItem {
/// User withdrew some of their available payout.
Withdrawal {
id: PayoutId,
status: PayoutStatus,
@@ -843,6 +953,7 @@ pub enum TransactionItem {
method_type: Option<PayoutMethodType>,
method_address: Option<String>,
},
/// User got a payout available for them to withdraw.
PayoutAvailable {
created: DateTime<Utc>,
payout_source: PayoutSource,
@@ -859,7 +970,17 @@ impl TransactionItem {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PayoutSource {
@@ -867,6 +988,10 @@ pub enum PayoutSource {
Affilites,
}
/// Get the history of when the authorized user got payouts available, and when
/// the user withdrew their payouts.
#[utoipa::path(responses((status = OK, body = Vec<TransactionItem>)))]
#[get("/history")]
pub async fn transaction_history(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -907,7 +1032,7 @@ pub async fn transaction_history(
});
let mut payouts_available = sqlx::query!(
"SELECT created, amount
"SELECT date_available, amount
FROM payouts_values
WHERE user_id = $1
AND NOW() >= date_available",
@@ -918,7 +1043,7 @@ pub async fn transaction_history(
let record = record
.wrap_internal_err("failed to fetch available payout record")?;
Ok(TransactionItem::PayoutAvailable {
created: record.created,
created: record.date_available,
payout_source: PayoutSource::CreatorRewards,
amount: record.amount,
})
@@ -935,7 +1060,8 @@ pub async fn transaction_history(
Ok(web::Json(txn_items))
}
#[delete("{id}")]
#[utoipa::path]
#[delete("/{id}")]
pub async fn cancel_payout(
info: web::Path<(PayoutId,)>,
req: HttpRequest,
@@ -995,10 +1121,16 @@ pub async fn cancel_payout(
)
.await?;
}
PayoutMethodType::Unknown => {
return Err(ApiError::InvalidInput(
"Payout cannot be cancelled!".to_string(),
));
PayoutMethodType::MuralPay => {
let payout_request_id = platform_id
.parse::<muralpay::PayoutRequestId>()
.wrap_request_err("invalid payout request ID")?;
payouts
.cancel_muralpay_payout_request(payout_request_id)
.await
.wrap_internal_err(
"failed to cancel payout request",
)?;
}
}
@@ -1047,7 +1179,8 @@ pub enum FormCompletionStatus {
Complete,
}
#[get("methods")]
#[utoipa::path]
#[get("/methods")]
pub async fn payment_methods(
payouts_queue: web::Data<PayoutsQueue>,
filter: web::Query<MethodFilter>,
@@ -1079,7 +1212,8 @@ pub struct UserBalance {
pub dates: HashMap<DateTime<Utc>, Decimal>,
}
#[get("balance")]
#[utoipa::path]
#[get("/balance")]
pub async fn get_balance(
req: HttpRequest,
pool: web::Data<PgPool>,
@@ -1217,7 +1351,9 @@ async fn update_compliance_status(
user_id: crate::database::models::ids::DBUserId,
) -> Result<Option<ComplianceCheck>, ApiError> {
let maybe_compliance =
users_compliance::UserCompliance::get_by_user_id(pg, user_id).await?;
users_compliance::UserCompliance::get_by_user_id(pg, user_id)
.await
.wrap_internal_err("failed to fetch user tax compliance")?;
let Some(mut compliance) = maybe_compliance else {
return Ok(None);
@@ -1233,7 +1369,9 @@ async fn update_compliance_status(
compliance_api_check_failed: false,
}))
} else {
let result = avalara1099::check_form(&compliance.reference_id).await?;
let result = avalara1099::check_form(&compliance.reference_id)
.await
.wrap_internal_err("failed to check form using Track1099")?;
let mut compliance_api_check_failed = false;
compliance.last_checked = Utc::now();
@@ -1311,7 +1449,8 @@ pub struct RevenueData {
pub creator_revenue: Decimal,
}
#[get("platform_revenue")]
#[utoipa::path]
#[get("/platform_revenue")]
pub async fn platform_revenue(
query: web::Query<RevenueQuery>,
pool: web::Data<PgPool>,