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

@@ -146,3 +146,8 @@ GOTENBERG_URL=http://labrinth-gotenberg:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none
MURALPAY_API_URL=https://api.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none

View File

@@ -147,3 +147,8 @@ GOTENBERG_URL=http://localhost:13000
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
ARCHON_URL=none
MURALPAY_API_URL=https://api-staging.muralpay.com
MURALPAY_API_KEY=none
MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT date_available, amount\n FROM payouts_values\n WHERE user_id = $1\n AND NOW() >= date_available",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_available",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "amount",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
},
"hash": "0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61"
}

View File

@@ -17,6 +17,7 @@ actix-rt = { workspace = true }
actix-web = { workspace = true }
actix-web-prom = { workspace = true, features = ["process"] }
actix-ws = { workspace = true }
arc-swap = { workspace = true }
argon2 = { workspace = true }
ariadne = { workspace = true }
async-stripe = { workspace = true, features = [
@@ -70,6 +71,7 @@ json-patch = { workspace = true }
lettre = { workspace = true }
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
modrinth-maxmind = { workspace = true }
muralpay = { workspace = true, features = ["utoipa"] }
murmur2 = { workspace = true }
paste = { workspace = true }
path-util = { workspace = true }
@@ -110,6 +112,7 @@ sqlx = { workspace = true, features = [
"rust_decimal",
"tls-rustls-aws-lc-rs",
] }
strum = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
tokio-stream = { workspace = true }

View File

@@ -1105,6 +1105,9 @@ COPY public.users (id, github_id, username, email, avatar_url, bio, created, rol
\.
INSERT INTO sessions (id, session, user_id, created, last_login, expires, refresh_expires, city, country, ip, os, platform, user_agent)
VALUES (93083445641246, 'mra_admin', 103587649610509, '2025-10-20 14:58:53.128901+00', '2025-10-20 14:58:53.128901+00', '2025-11-03 14:58:53.128901+00', '2025-12-19 14:58:53.128901+00', '', '', '127.0.0.1', 'Linux', 'Chrome', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36');
VALUES (93083445641246, 'mra_admin', 103587649610509, '2025-10-20 14:58:53.128901+00', '2025-10-20 14:58:53.128901+00', '2030-11-03 14:58:53.128901+00', '2030-12-19 14:58:53.128901+00', '', '', '127.0.0.1', 'Linux', 'Chrome', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36');
INSERT INTO payouts_values (user_id, amount, created, date_available)
VALUES (103587649610509, 1000.00000000000000000000, '2025-10-23 00:00:00+00', '2025-10-23 00:00:00+00');
COMMIT;

View File

@@ -38,7 +38,7 @@ impl DBPayout {
self.fee,
self.user_id.0,
self.status.as_str(),
self.method.map(|x| x.as_str()),
self.method.as_ref().map(|x| x.as_str()),
self.method_address,
self.platform_id,
)
@@ -84,7 +84,7 @@ impl DBPayout {
created: r.created,
status: PayoutStatus::from_string(&r.status),
amount: r.amount,
method: r.method.map(|x| PayoutMethodType::from_string(&x)),
method: r.method.and_then(|x| PayoutMethodType::from_string(&x)),
method_address: r.method_address,
platform_id: r.platform_id,
fee: r.fee,

View File

@@ -5,7 +5,16 @@ use sqlx::{query, query_scalar};
use std::fmt;
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
Debug,
Default,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
pub enum FormType {
#[serde(rename = "W-8BEN")]

View File

@@ -527,5 +527,10 @@ pub fn check_env_vars() -> bool {
failed |= check_var::<String>("ARCHON_URL");
failed |= check_var::<String>("MURALPAY_API_URL");
failed |= check_var::<String>("MURALPAY_API_KEY");
failed |= check_var::<String>("MURALPAY_TRANSFER_API_KEY");
failed |= check_var::<String>("MURALPAY_SOURCE_ACCOUNT_ID");
failed
}

View File

@@ -1,4 +1,6 @@
use crate::models::ids::PayoutId;
use std::{cmp, collections::HashMap, fmt};
use crate::{models::ids::PayoutId, queue::payouts::mural::MuralPayoutRequest};
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
@@ -37,13 +39,47 @@ impl Payout {
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "method", rename_all = "lowercase")]
#[expect(
clippy::large_enum_variant,
reason = "acceptable since values of this type are not moved much"
)]
pub enum PayoutMethodRequest {
Venmo,
PayPal,
Tremendous { method_details: TremendousDetails },
MuralPay { method_details: MuralPayDetails },
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum PayoutMethodType {
Venmo,
PayPal,
Tremendous,
Unknown,
MuralPay,
}
impl PayoutMethodRequest {
pub fn method_type(&self) -> PayoutMethodType {
match self {
Self::Venmo => PayoutMethodType::Venmo,
Self::PayPal => PayoutMethodType::PayPal,
Self::Tremendous { .. } => PayoutMethodType::Tremendous,
Self::MuralPay { .. } => PayoutMethodType::MuralPay,
}
}
}
impl std::fmt::Display for PayoutMethodType {
@@ -52,27 +88,85 @@ impl std::fmt::Display for PayoutMethodType {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TremendousDetails {
pub delivery_email: String,
#[schema(inline)]
pub currency: Option<TremendousCurrency>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TremendousCurrency {
Usd,
Gbp,
Cad,
Eur,
Aud,
Chf,
Czk,
Dkk,
Mxn,
Nok,
Nzd,
Pln,
Sek,
Sgd,
}
impl fmt::Display for TremendousCurrency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = serde_json::to_value(self).map_err(|_| fmt::Error)?;
let s = s.as_str().ok_or(fmt::Error)?;
write!(f, "{s}")
}
}
#[derive(Debug, Deserialize)]
pub struct TremendousForexResponse {
pub forex: HashMap<String, Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MuralPayDetails {
pub payout_details: MuralPayoutRequest,
pub recipient_info: muralpay::PayoutRecipientInfo,
}
impl PayoutMethodType {
pub fn as_str(&self) -> &'static str {
match self {
PayoutMethodType::Venmo => "venmo",
PayoutMethodType::PayPal => "paypal",
PayoutMethodType::Tremendous => "tremendous",
PayoutMethodType::Unknown => "unknown",
PayoutMethodType::MuralPay => "muralpay",
}
}
pub fn from_string(string: &str) -> PayoutMethodType {
pub fn from_string(string: &str) -> Option<PayoutMethodType> {
match string {
"venmo" => PayoutMethodType::Venmo,
"paypal" => PayoutMethodType::PayPal,
"tremendous" => PayoutMethodType::Tremendous,
_ => PayoutMethodType::Unknown,
"venmo" => Some(PayoutMethodType::Venmo),
"paypal" => Some(PayoutMethodType::PayPal),
"tremendous" => Some(PayoutMethodType::Tremendous),
"muralpay" => Some(PayoutMethodType::MuralPay),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
#[derive(
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum PayoutStatus {
Success,
@@ -119,6 +213,8 @@ pub struct PayoutMethod {
#[serde(rename = "type")]
pub type_: PayoutMethodType,
pub name: String,
pub category: Option<String>,
#[serde(skip_serializing)]
pub supported_countries: Vec<String>,
pub image_url: Option<String>,
pub image_logo_url: Option<String>,
@@ -136,6 +232,15 @@ pub struct PayoutMethodFee {
pub max: Option<Decimal>,
}
impl PayoutMethodFee {
pub fn compute_fee(&self, value: Decimal) -> Decimal {
cmp::min(
cmp::max(self.min, self.percentage * value),
self.max.unwrap_or(Decimal::MAX),
)
}
}
#[derive(Clone)]
pub struct PayoutDecimal(pub Decimal);

View File

@@ -2,21 +2,28 @@ use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::payouts_values_notifications;
use crate::database::redis::RedisPool;
use crate::models::payouts::{
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee,
PayoutMethodType,
MuralPayDetails, PayoutDecimal, PayoutInterval, PayoutMethod,
PayoutMethodFee, PayoutMethodRequest, PayoutMethodType,
TremendousForexResponse,
};
use crate::models::projects::MonetizationStatus;
use crate::queue::payouts::mural::MuralPayoutRequest;
use crate::routes::ApiError;
use crate::util::env::env_var;
use crate::util::error::Context;
use crate::util::webhook::{
PayoutSourceAlertType, send_slack_payout_source_alert_webhook,
};
use arc_swap::ArcSwapOption;
use base64::Engine;
use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc};
use dashmap::DashMap;
use eyre::{Result, eyre};
use futures::TryStreamExt;
use muralpay::MuralPay;
use reqwest::Method;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::{Decimal, dec};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -24,11 +31,19 @@ use sqlx::PgPool;
use sqlx::postgres::PgQueryResult;
use std::collections::HashMap;
use tokio::sync::RwLock;
use tracing::{error, info};
use tracing::{error, info, warn};
pub mod mural;
pub struct PayoutsQueue {
credential: RwLock<Option<PayPalCredentials>>,
payout_options: RwLock<Option<PayoutMethods>>,
pub muralpay: ArcSwapOption<MuralPayConfig>,
}
pub struct MuralPayConfig {
pub client: MuralPay,
pub source_account_id: muralpay::AccountId,
}
#[derive(Clone, Debug)]
@@ -55,12 +70,102 @@ impl Default for PayoutsQueue {
Self::new()
}
}
fn create_muralpay() -> Result<MuralPayConfig> {
let api_url = env_var("MURALPAY_API_URL")?;
let api_key = env_var("MURALPAY_API_KEY")?;
let transfer_api_key = env_var("MURALPAY_TRANSFER_API_KEY")?;
let source_account_id = env_var("MURALPAY_SOURCE_ACCOUNT_ID")?
.parse::<muralpay::AccountId>()
.wrap_err("failed to parse source account ID")?;
let client = MuralPay::new(api_url, api_key, Some(transfer_api_key));
Ok(MuralPayConfig {
client,
source_account_id,
})
}
fn create_muralpay_methods() -> Vec<PayoutMethod> {
let all_countries = rust_iso3166::ALL
.iter()
.map(|x| x.alpha2)
.collect::<Vec<_>>();
let currencies = vec![
("blockchain_usdc_polygon", "USDC on Polygon", all_countries),
("fiat_mxn", "MXN", vec!["MX"]),
("fiat_brl", "BRL", vec!["BR"]),
("fiat_clp", "CLP", vec!["CL"]),
("fiat_crc", "CRC", vec!["CR"]),
("fiat_pen", "PEN", vec!["PE"]),
// ("fiat_dop", "DOP"), // unsupported in API
// ("fiat_uyu", "UYU"), // unsupported in API
("fiat_ars", "ARS", vec!["AR"]),
("fiat_cop", "COP", vec!["CO"]),
("fiat_usd", "USD", vec!["US"]),
("fiat_usd-peru", "USD Peru", vec!["PE"]),
// ("fiat_usd-panama", "USD Panama"), // by request
(
"fiat_eur",
"EUR",
vec![
"DE", "FR", "IT", "ES", "NL", "BE", "AT", "PT", "FI", "IE",
"GR", "LU", "CY", "MT", "SK", "SI", "EE", "LV", "LT",
],
),
];
currencies
.into_iter()
.map(|(id, currency, countries)| PayoutMethod {
id: id.to_string(),
type_: PayoutMethodType::MuralPay,
name: format!("Mural Pay - {currency}"),
category: None,
supported_countries: countries
.iter()
.map(|s| s.to_string())
.collect(),
image_url: None,
image_logo_url: None,
interval: PayoutInterval::Standard {
// Different countries and currencies supported by Mural have different fees.
min: match id {
// Due to relatively low volume of Peru withdrawals, fees are higher,
// so we need to raise the minimum to cover these fees.
"fiat_usd-peru" => Decimal::from(10),
// USDC has much lower fees.
"blockchain_usdc_polygon" => {
Decimal::from(10) / Decimal::from(100)
}
_ => Decimal::from(5),
},
max: Decimal::from(10_000),
},
fee: PayoutMethodFee {
percentage: Decimal::from(1) / Decimal::from(100),
min: Decimal::ZERO,
max: Some(Decimal::ZERO),
},
})
.collect()
}
// Batches payouts and handles token refresh
impl PayoutsQueue {
pub fn new() -> Self {
let muralpay = create_muralpay()
.inspect_err(|err| {
warn!("Failed to create Mural Pay client: {err:#?}")
})
.ok();
PayoutsQueue {
credential: RwLock::new(None),
payout_options: RwLock::new(None),
muralpay: ArcSwapOption::from_pointee(muralpay),
}
}
@@ -272,6 +377,7 @@ impl PayoutsQueue {
#[derive(Deserialize)]
struct TremendousError {
message: String,
payload: Option<serde_json::Value>,
}
let err =
@@ -283,7 +389,10 @@ impl PayoutsQueue {
)
})?;
return Err(ApiError::Payments(err.message));
return Err(ApiError::Payments(format!(
"Tremendous error: {} ({:?})",
err.message, err.payload
)));
}
return Err(ApiError::Payments(
@@ -304,198 +413,23 @@ impl PayoutsQueue {
let mut methods = Vec::new();
#[derive(Deserialize)]
pub struct Sku {
pub min: Decimal,
pub max: Decimal,
}
#[derive(Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ProductImageType {
Card,
Logo,
}
#[derive(Deserialize)]
pub struct ProductImage {
pub src: String,
#[serde(rename = "type")]
pub type_: ProductImageType,
}
#[derive(Deserialize)]
pub struct ProductCountry {
pub abbr: String,
}
#[derive(Deserialize)]
pub struct Product {
pub id: String,
pub category: String,
pub name: String,
// pub description: String,
// pub disclosure: String,
pub skus: Vec<Sku>,
pub currency_codes: Vec<String>,
pub countries: Vec<ProductCountry>,
pub images: Vec<ProductImage>,
}
#[derive(Deserialize)]
pub struct TremendousResponse {
pub products: Vec<Product>,
}
let response = queue
.make_tremendous_request::<(), TremendousResponse>(
Method::GET,
"products",
None,
)
.await?;
for product in response.products {
const BLACKLISTED_IDS: &[&str] = &[
// physical visa
"A2J05SWPI2QG",
// crypto
"1UOOSHUUYTAM",
"5EVJN47HPDFT",
"NI9M4EVAVGFJ",
"VLY29QHTMNGT",
"7XU98H109Y3A",
"0CGEDFP2UIKV",
"PDYLQU0K073Y",
"HCS5Z7O2NV5G",
"IY1VMST1MOXS",
"VRPZLJ7HCA8X",
// bitcard (crypto)
"GWQQS5RM8IZS",
"896MYD4SGOGZ",
"PWLEN1VZGMZA",
"A2VRM96J5K5W",
"HV9ICIM3JT7P",
"K2KLSPVWC2Q4",
"HRBRQLLTDF95",
"UUBYLZVK7QAB",
"BH8W3XEDEOJN",
"7WGE043X1RYQ",
"2B13MHUZZVTF",
"JN6R44P86EYX",
"DA8H43GU84SO",
"QK2XAQHSDEH4",
"J7K1IQFS76DK",
"NL4JQ2G7UPRZ",
"OEFTMSBA5ELH",
"A3CQK6UHNV27",
];
const SUPPORTED_METHODS: &[&str] = &[
"merchant_cards",
"merchant_card",
"visa",
"bank",
"ach",
"visa_card",
"charity",
];
if !SUPPORTED_METHODS.contains(&&*product.category)
|| BLACKLISTED_IDS.contains(&&*product.id)
{
continue;
};
let method = PayoutMethod {
id: product.id,
type_: PayoutMethodType::Tremendous,
name: product.name.clone(),
supported_countries: product
.countries
.into_iter()
.map(|x| x.abbr)
.collect(),
image_logo_url: product
.images
.iter()
.find(|x| x.type_ == ProductImageType::Logo)
.map(|x| x.src.clone()),
image_url: product
.images
.into_iter()
.find(|x| x.type_ == ProductImageType::Card)
.map(|x| x.src),
interval: if product.skus.len() > 1 {
let mut values = product
.skus
.into_iter()
.map(|x| PayoutDecimal(x.min))
.collect::<Vec<_>>();
values.sort_by(|a, b| a.0.cmp(&b.0));
PayoutInterval::Fixed { values }
} else if let Some(first) = product.skus.first() {
PayoutInterval::Standard {
min: first.min,
max: first.max,
}
} else {
PayoutInterval::Standard {
min: Decimal::ZERO,
max: Decimal::from(5_000),
}
},
fee: if product.category == "ach" {
PayoutMethodFee {
percentage: Decimal::from(4) / Decimal::from(100),
min: Decimal::from(1) / Decimal::from(4),
max: None,
}
} else {
PayoutMethodFee {
percentage: Decimal::default(),
min: Decimal::default(),
max: None,
}
},
};
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
if let PayoutInterval::Fixed { .. } = method.interval
&& !product.currency_codes.contains(&"USD".to_string())
{
continue;
match get_tremendous_payout_methods(queue).await {
Ok(mut tremendous_methods) => {
methods.append(&mut tremendous_methods);
}
methods.push(method);
}
const UPRANK_IDS: &[&str] =
&["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"];
const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"];
methods.sort_by(|a, b| {
let a_top = UPRANK_IDS.contains(&&*a.id);
let a_bottom = DOWNRANK_IDS.contains(&&*a.id);
let b_top = UPRANK_IDS.contains(&&*b.id);
let b_bottom = DOWNRANK_IDS.contains(&&*b.id);
match (a_top, a_bottom, b_top, b_bottom) {
(true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically
(_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically
(true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first
(_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first
(_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first
(_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first
(_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically
Err(err) => {
warn!(
"Failed to fetch Tremendous payout methods: {err:#?}"
);
}
});
}
{
let paypal_us = PayoutMethod {
id: "paypal_us".to_string(),
type_: PayoutMethodType::PayPal,
name: "PayPal".to_string(),
category: None,
supported_countries: vec!["US".to_string()],
image_url: None,
image_logo_url: None,
@@ -519,30 +453,7 @@ impl PayoutsQueue {
methods.insert(1, venmo)
}
methods.insert(
2,
PayoutMethod {
id: "paypal_in".to_string(),
type_: PayoutMethodType::PayPal,
name: "PayPal".to_string(),
supported_countries: rust_iso3166::ALL
.iter()
.filter(|x| x.alpha2 != "US")
.map(|x| x.alpha2.to_string())
.collect(),
image_url: None,
image_logo_url: None,
interval: PayoutInterval::Standard {
min: Decimal::from(1) / Decimal::from(4),
max: Decimal::from(100_000),
},
fee: PayoutMethodFee {
percentage: Decimal::from(2) / Decimal::from(100),
min: Decimal::ZERO,
max: Some(Decimal::from(20)),
},
},
);
methods.extend(create_muralpay_methods());
let new_options = PayoutMethods {
options: methods,
@@ -699,6 +610,333 @@ impl PayoutsQueue {
/ Decimal::from(100),
}))
}
pub async fn calculate_fees(
&self,
request: &PayoutMethodRequest,
method_id: &str,
amount: Decimal,
) -> 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: dec!(0),
platform_fee: amount * MURAL_FEE,
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: fee_total.token_amount,
platform_fee: amount * MURAL_FEE,
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 = method.fee.compute_fee(amount);
PayoutFees {
method_fee: fee,
platform_fee: dec!(0),
exchange_rate: None,
}
}
PayoutMethodRequest::Tremendous { method_details } => {
let method = get_method.await?;
let fee = method.fee.compute_fee(amount);
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(&currency_code).wrap_request_err_with(
|| eyre!("no Tremendous forex data for {currency}"),
)?;
Some(*exchange_rate)
} else {
None
};
PayoutFees {
method_fee: fee,
platform_fee: dec!(0),
exchange_rate,
}
}
};
Ok(fees)
}
}
#[derive(Debug, Clone)]
pub struct PayoutFees {
/// Fee which is taken by the underlying method we're using.
///
/// For example, if a user withdraws $10.00 and the method takes a
/// 10% cut, then we submit a payout request of $10.00 to the method,
/// and only $9.00 will be sent to the recipient.
pub method_fee: Decimal,
/// Fee which we keep and don't pass to the underlying method.
///
/// For example, if a user withdraws $10.00 and the method takes a
/// 10% cut, then we submit a payout request of $9.00, and the $1.00 stays
/// in our account.
pub platform_fee: Decimal,
/// How much is 1 USD worth in the target currency?
pub exchange_rate: Option<Decimal>,
}
impl PayoutFees {
pub fn total_fee(&self) -> Decimal {
self.method_fee + self.platform_fee
}
}
async fn get_tremendous_payout_methods(
queue: &PayoutsQueue,
) -> Result<Vec<PayoutMethod>> {
#[derive(Debug, Deserialize)]
struct Sku {
min: Decimal,
max: Decimal,
}
#[derive(Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
enum ProductImageType {
Card,
Logo,
}
#[derive(Deserialize)]
struct ProductImage {
src: String,
#[serde(rename = "type")]
type_: ProductImageType,
}
#[derive(Deserialize)]
struct ProductCountry {
abbr: String,
}
#[derive(Deserialize)]
struct Product {
id: String,
category: String,
name: String,
// description: String,
// disclosure: String,
skus: Vec<Sku>,
currency_codes: Vec<String>,
countries: Vec<ProductCountry>,
images: Vec<ProductImage>,
}
#[derive(Deserialize)]
struct TremendousResponse {
products: Vec<Product>,
}
let response = queue
.make_tremendous_request::<(), TremendousResponse>(
Method::GET,
"products",
None,
)
.await?;
let mut methods = Vec::new();
for product in response.products {
const BLACKLISTED_IDS: &[&str] = &[
// physical visa
"A2J05SWPI2QG",
// crypto
"1UOOSHUUYTAM",
"5EVJN47HPDFT",
"NI9M4EVAVGFJ",
"VLY29QHTMNGT",
"7XU98H109Y3A",
"0CGEDFP2UIKV",
"PDYLQU0K073Y",
"HCS5Z7O2NV5G",
"IY1VMST1MOXS",
"VRPZLJ7HCA8X",
// bitcard (crypto)
"GWQQS5RM8IZS",
"896MYD4SGOGZ",
"PWLEN1VZGMZA",
"A2VRM96J5K5W",
"HV9ICIM3JT7P",
"K2KLSPVWC2Q4",
"HRBRQLLTDF95",
"UUBYLZVK7QAB",
"BH8W3XEDEOJN",
"7WGE043X1RYQ",
"2B13MHUZZVTF",
"JN6R44P86EYX",
"DA8H43GU84SO",
"QK2XAQHSDEH4",
"J7K1IQFS76DK",
"NL4JQ2G7UPRZ",
"OEFTMSBA5ELH",
"A3CQK6UHNV27",
];
const SUPPORTED_METHODS: &[&str] = &[
"merchant_cards",
"merchant_card",
"bank",
"charity",
"paypal",
"venmo",
];
if !SUPPORTED_METHODS.contains(&&*product.category)
|| BLACKLISTED_IDS.contains(&&*product.id)
{
continue;
};
// https://help.tremendous.com/hc/en-us/articles/41472317536787-Premium-reward-options
let fee = match product.category.as_str() {
"paypal" | "venmo" => PayoutMethodFee {
percentage: dec!(0.06),
min: dec!(1.00),
max: Some(dec!(25.00)),
},
_ => PayoutMethodFee {
percentage: dec!(0),
min: dec!(0),
max: None,
},
};
let method = PayoutMethod {
id: product.id,
type_: PayoutMethodType::Tremendous,
name: product.name.clone(),
category: Some(product.category.clone()),
supported_countries: product
.countries
.into_iter()
.map(|x| x.abbr)
.collect(),
image_logo_url: product
.images
.iter()
.find(|x| x.type_ == ProductImageType::Logo)
.map(|x| x.src.clone()),
image_url: product
.images
.into_iter()
.find(|x| x.type_ == ProductImageType::Card)
.map(|x| x.src),
interval: if product.skus.len() > 1 {
let mut values = product
.skus
.into_iter()
.map(|x| PayoutDecimal(x.min))
.collect::<Vec<_>>();
values.sort_by(|a, b| a.0.cmp(&b.0));
PayoutInterval::Fixed { values }
} else if let Some(first) = product.skus.first() {
PayoutInterval::Standard {
min: first.min,
max: first.max,
}
} else {
PayoutInterval::Standard {
min: Decimal::ZERO,
max: Decimal::from(5_000),
}
},
fee,
};
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
if let PayoutInterval::Fixed { .. } = method.interval
&& !product.currency_codes.contains(&"USD".to_string())
{
continue;
}
methods.push(method);
}
const UPRANK_IDS: &[&str] =
&["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"];
const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"];
methods.sort_by(|a, b| {
let a_top = UPRANK_IDS.contains(&&*a.id);
let a_bottom = DOWNRANK_IDS.contains(&&*a.id);
let b_top = UPRANK_IDS.contains(&&*b.id);
let b_bottom = DOWNRANK_IDS.contains(&&*b.id);
match (a_top, a_bottom, b_top, b_bottom) {
(true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically
(_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically
(true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first
(_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first
(_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first
(_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first
(_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically
}
});
Ok(methods)
}
#[derive(Deserialize)]
@@ -1133,6 +1371,7 @@ pub async fn insert_bank_balances_and_webhook(
let paypal_result = PayoutsQueue::get_paypal_balance().await;
let brex_result = PayoutsQueue::get_brex_balance().await;
let tremendous_result = payouts.get_tremendous_balance().await;
let mural_result = payouts.get_mural_balance().await;
let mut insert_account_types = Vec::new();
let mut insert_amounts = Vec::new();
@@ -1163,6 +1402,9 @@ pub async fn insert_bank_balances_and_webhook(
if let Ok(Some(ref tremendous)) = tremendous_result {
add_balance("tremendous", tremendous);
}
if let Ok(Some(ref mural)) = mural_result {
add_balance("mural", mural);
}
let inserted = sqlx::query_scalar!(
r#"

View File

@@ -0,0 +1,180 @@
use ariadne::ids::UserId;
use eyre::Result;
use muralpay::{MuralError, TokenFeeRequest};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::{
queue::payouts::{AccountBalance, PayoutsQueue},
routes::ApiError,
util::error::Context,
};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MuralPayoutRequest {
Fiat {
bank_name: String,
bank_account_owner: String,
fiat_and_rail_details: muralpay::FiatAndRailDetails,
},
Blockchain {
wallet_address: String,
},
}
impl PayoutsQueue {
pub async fn compute_muralpay_fees(
&self,
amount: Decimal,
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,
},
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,
user_id: UserId,
amount: muralpay::TokenAmount,
payout_details: MuralPayoutRequest,
recipient_info: muralpay::PayoutRecipientInfo,
) -> Result<muralpay::PayoutRequest, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let payout_details = match payout_details {
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,
},
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,
payout_details,
recipient_info,
supporting_details: None,
};
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::Request(err.into()),
err => ApiError::Internal(err.into()),
})?;
// 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 {
muralpay
.client
.execute_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 {
muralpay
.client
.cancel_payout_request(payout_request.id)
.await
.wrap_internal_err(
"failed to cancel unexecuted payout request",
)?;
return Err(ApiError::Internal(err));
}
Ok(payout_request)
}
pub async fn cancel_muralpay_payout_request(
&self,
id: muralpay::PayoutRequestId,
) -> Result<()> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_err("Mural Pay client not available")?;
muralpay.client.cancel_payout_request(id).await?;
Ok(())
}
pub async fn get_mural_balance(&self) -> Result<Option<AccountBalance>> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_err("Mural Pay client not available")?;
let account = muralpay
.client
.get_account(muralpay.source_account_id)
.await?;
let details = account
.account_details
.wrap_err("source account does not have details")?;
let available = details
.balances
.iter()
.map(|balance| {
if balance.token_symbol == muralpay::USDC {
balance.token_amount
} else {
Decimal::ZERO
}
})
.sum::<Decimal>();
Ok(Some(AccountBalance {
available,
pending: Decimal::ZERO,
}))
}
}

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>,

View File

@@ -1,5 +1,12 @@
use std::str::FromStr;
use eyre::{Context, eyre};
pub fn env_var(key: &str) -> eyre::Result<String> {
dotenvy::var(key)
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))
}
pub fn parse_var<T: FromStr>(var: &str) -> Option<T> {
dotenvy::var(var).ok().and_then(|i| i.parse().ok())
}

View File

@@ -5,111 +5,253 @@ use std::{
use crate::routes::ApiError;
/// Allows wrapping [`Result`]s and [`Option`]s into [`Result<T, ApiError>`]s.
#[allow(
clippy::missing_errors_doc,
reason = "this trait's purpose is improving error handling"
)]
pub trait Context<T, E>: Sized {
fn wrap_request_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
/// Maps the error variant into an [`eyre::Report`], creating the message
/// using `f`.
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
where
D: Debug + Display + Send + Sync + 'static;
D: Send + Sync + Debug + Display + 'static;
fn wrap_request_err<D>(self, msg: D) -> Result<T, ApiError>
/// Maps the error variant into an [`eyre::Report`] with the given message.
#[inline]
fn wrap_err<D>(self, msg: D) -> Result<T, eyre::Report>
where
D: Debug + Display + Send + Sync + 'static,
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_request_err_with(|| msg)
self.wrap_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::Internal`] using the closure to create the message.
#[inline]
fn wrap_internal_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Debug + Display + Send + Sync + 'static;
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::Internal)
}
/// Maps the error variant into an [`ApiError::Internal`] with the given message.
#[inline]
fn wrap_internal_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Debug + Display + Send + Sync + 'static,
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_internal_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::Request`] using the closure to create the message.
#[inline]
fn wrap_request_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::Request)
}
/// Maps the error variant into an [`ApiError::Request`] with the given message.
#[inline]
fn wrap_request_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_request_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::Auth`] using the closure to create the message.
#[inline]
fn wrap_auth_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::Auth)
}
/// Maps the error variant into an [`ApiError::Auth`] with the given message.
#[inline]
fn wrap_auth_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_auth_err_with(|| msg)
}
}
impl<T, E> Context<T, E> for Result<T, E>
where
E: std::error::Error + Send + Sync + Sized + 'static,
Self: eyre::WrapErr<T, E>,
{
fn wrap_request_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
where
D: Display + Send + Sync + 'static,
D: Send + Sync + Debug + Display + 'static,
{
self.map_err(|err| {
let report = eyre::Report::new(err).wrap_err(f());
ApiError::Request(report)
})
}
fn wrap_internal_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Display + Send + Sync + 'static,
{
self.map_err(|err| {
let report = eyre::Report::new(err).wrap_err(f());
ApiError::Internal(report)
})
eyre::WrapErr::wrap_err_with(self, f)
}
}
impl<T> Context<T, Infallible> for Option<T> {
fn wrap_request_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
where
D: Debug + Display + Send + Sync + 'static,
D: Send + Sync + Debug + Display + 'static,
{
self.ok_or_else(|| ApiError::Request(eyre::Report::msg(f())))
}
fn wrap_internal_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Debug + Display + Send + Sync + 'static,
{
self.ok_or_else(|| ApiError::Internal(eyre::Report::msg(f())))
self.ok_or_else(|| eyre::Report::msg(f()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{ResponseError, http::StatusCode};
fn sqlx_result() -> Result<(), sqlx::Error> {
Err(sqlx::Error::RowNotFound)
}
// these just test that code written with the above API compiles
fn propagating() -> Result<(), ApiError> {
sqlx_result()
.wrap_internal_err("failed to perform database operation")?;
sqlx_result().wrap_request_err("invalid request parameter")?;
None::<()>.wrap_internal_err("something is missing")?;
Ok(())
}
// just so we don't get a dead code warning
#[test]
fn test_propagating() {
_ = propagating();
fn test_api_error_display() {
let error = ApiError::Internal(eyre::eyre!("test internal error"));
assert!(error.to_string().contains("test internal error"));
let error = ApiError::Request(eyre::eyre!("test request error"));
assert!(error.to_string().contains("test request error"));
let error = ApiError::Auth(eyre::eyre!("test auth error"));
assert!(error.to_string().contains("test auth error"));
}
#[test]
fn test_api_error_debug() {
let error = ApiError::Internal(eyre::eyre!("test error"));
let debug_str = format!("{error:?}");
assert!(debug_str.contains("Internal"));
assert!(debug_str.contains("test error"));
}
#[test]
fn test_response_error_status_codes() {
let internal_error = ApiError::Internal(eyre::eyre!("internal error"));
assert_eq!(
internal_error.status_code(),
StatusCode::INTERNAL_SERVER_ERROR
);
let request_error = ApiError::Request(eyre::eyre!("request error"));
assert_eq!(request_error.status_code(), StatusCode::BAD_REQUEST);
let auth_error = ApiError::Auth(eyre::eyre!("auth error"));
assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_response_error_response() {
let error = ApiError::Request(eyre::eyre!("test request error"));
let response = error.error_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// Skip the body parsing test as it requires async and is more complex
// The important thing is that the error response is created correctly
}
#[test]
fn test_context_trait_result() {
let result: Result<i32, std::io::Error> = Ok(42);
let wrapped = result.wrap_err("context message");
assert_eq!(wrapped.unwrap(), 42);
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
let wrapped = result.wrap_err("context message");
assert!(wrapped.is_err());
assert!(wrapped.unwrap_err().to_string().contains("context message"));
}
#[test]
fn test_context_trait_option() {
let option: Option<i32> = Some(42);
let wrapped = option.wrap_err("context message");
assert_eq!(wrapped.unwrap(), 42);
let option: Option<i32> = None;
let wrapped = option.wrap_err("context message");
assert!(wrapped.is_err());
assert_eq!(wrapped.unwrap_err().to_string(), "context message");
}
#[test]
fn test_context_trait_internal_error() {
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
let wrapped = result.wrap_internal_err("internal error context");
assert!(wrapped.is_err());
match wrapped.unwrap_err() {
ApiError::Internal(report) => {
assert!(report.to_string().contains("internal error context"));
}
_ => panic!("Expected Internal error"),
}
}
#[test]
fn test_context_trait_request_error() {
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
let wrapped = result.wrap_request_err("request error context");
assert!(wrapped.is_err());
match wrapped.unwrap_err() {
ApiError::Request(report) => {
assert!(report.to_string().contains("request error context"));
}
_ => panic!("Expected Request error"),
}
}
#[test]
fn test_context_trait_auth_error() {
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
let wrapped = result.wrap_auth_err("auth error context");
assert!(wrapped.is_err());
match wrapped.unwrap_err() {
ApiError::Auth(report) => {
assert!(report.to_string().contains("auth error context"));
}
_ => panic!("Expected Auth error"),
}
}
#[test]
fn test_context_trait_with_closure() {
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
let wrapped =
result.wrap_err_with(|| format!("context with {}", "dynamic"));
assert!(wrapped.is_err());
assert!(
wrapped
.unwrap_err()
.to_string()
.contains("context with dynamic")
);
}
}