You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json
generated
Normal file
28
apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(¤cy_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#"
|
||||
180
apps/labrinth/src/queue/payouts/mural.rs
Normal file
180
apps/labrinth/src/queue/payouts/mural.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/labrinth/src/routes/internal/mural.rs
Normal file
28
apps/labrinth/src/routes/internal/mural.rs
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(¤cy_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>,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user