diff --git a/CLAUDE.md b/CLAUDE.md index d84f325eea..2f6a226977 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,3 +57,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse ### Postgres Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance. + +# Guidelines + +- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to. diff --git a/Cargo.lock b/Cargo.lock index 0698440011..792031ea2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -1872,7 +1878,7 @@ dependencies = [ "bitflags 2.9.4", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -2972,6 +2978,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2979,7 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2993,6 +3008,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3954,6 +3975,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -4609,6 +4646,7 @@ dependencies = [ "actix-web", "actix-web-prom", "actix-ws", + "arc-swap", "argon2", "ariadne", "async-stripe", @@ -4644,6 +4682,7 @@ dependencies = [ "lettre", "meilisearch-sdk", "modrinth-maxmind", + "muralpay", "murmur2", "paste", "path-util", @@ -4667,6 +4706,7 @@ dependencies = [ "sha2", "spdx", "sqlx", + "strum", "thiserror 2.0.17", "tikv-jemalloc-ctl", "tikv-jemallocator", @@ -5241,6 +5281,31 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "muralpay" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "clap", + "color-eyre", + "derive_more 2.0.1", + "dotenvy", + "eyre", + "reqwest", + "rust_decimal", + "rust_iso3166", + "secrecy", + "serde", + "serde_json", + "serde_with", + "strum", + "tokio", + "tracing-subscriber", + "utoipa", + "uuid 1.18.1", +] + [[package]] name = "murmur2" version = "0.1.0" @@ -5277,6 +5342,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5884,12 +5966,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -7191,11 +7311,13 @@ dependencies = [ "http-body-util", "hyper 1.7.0", "hyper-rustls 0.27.7", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -7207,6 +7329,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.2", @@ -7430,6 +7553,7 @@ dependencies = [ "num-traits", "rand 0.8.5", "rkyv", + "rust_decimal_macros", "serde", "serde_json", ] @@ -7777,6 +7901,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -8387,7 +8520,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -8757,6 +8890,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "subtle" version = "2.6.1" @@ -9723,6 +9877,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -10381,6 +10545,7 @@ dependencies = [ "quote", "regex", "syn 2.0.106", + "uuid 1.18.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d7efdc3e0a..0db90446c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ actix-rt = "2.11.0" actix-web = "4.11.0" actix-web-prom = "0.10.0" actix-ws = "0.3.0" +arc-swap = "1.7.1" argon2 = { version = "0.5.3", features = ["std"] } ariadne = { path = "packages/ariadne" } async-compression = { version = "0.4.32", default-features = false } @@ -109,6 +110,7 @@ maxminddb = "0.26.0" meilisearch-sdk = { version = "0.30.0", default-features = false } modrinth-maxmind = { path = "packages/modrinth-maxmind" } modrinth-util = { path = "packages/modrinth-util" } +muralpay = { path = "packages/muralpay" } murmur2 = "0.1.0" native-dialog = "0.9.2" notify = { version = "8.2.0", default-features = false } @@ -139,6 +141,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [ ] } rustls = "0.23.32" rusty-money = "0.4.1" +secrecy = "0.10.3" sentry = { version = "0.45.0", default-features = false, features = [ "backtrace", "contexts", @@ -161,6 +164,7 @@ sha2 = "0.10.9" shlex = "1.3.0" spdx = "0.12.0" sqlx = { version = "0.8.6", default-features = false } +strum = "0.27.2" sysinfo = { version = "0.37.2", default-features = false } tar = "0.4.44" tauri = "2.8.5" diff --git a/_typos.toml b/_typos.toml index 4dd9b96b8f..5961cd5dc5 100644 --- a/_typos.toml +++ b/_typos.toml @@ -9,7 +9,7 @@ extend-exclude = [ # contains licenses like `CC-BY-ND-4.0` "packages/moderation/src/data/stages/license.ts", # contains payment card IDs like `IY1VMST1MOXS` which are flagged - "apps/labrinth/src/queue/payouts.rs", + "apps/labrinth/src/queue/payouts/mod.rs", ] [default.extend-words] diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index d272a7e435..2368726e8b 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -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 diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 45372c45f3..37358ad82d 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -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 diff --git a/apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json b/apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json new file mode 100644 index 0000000000..33706d7a89 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json @@ -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" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 055e2e5815..9502fbdcc5 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -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 } diff --git a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql index 5f4b0d20ef..6a6dd72605 100644 --- a/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql +++ b/apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql @@ -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; diff --git a/apps/labrinth/src/database/models/payout_item.rs b/apps/labrinth/src/database/models/payout_item.rs index 0f0fbfc279..d8d82b059b 100644 --- a/apps/labrinth/src/database/models/payout_item.rs +++ b/apps/labrinth/src/database/models/payout_item.rs @@ -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, diff --git a/apps/labrinth/src/database/models/users_compliance.rs b/apps/labrinth/src/database/models/users_compliance.rs index 47690eadbe..60b5cb1bf9 100644 --- a/apps/labrinth/src/database/models/users_compliance.rs +++ b/apps/labrinth/src/database/models/users_compliance.rs @@ -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")] diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index b35e4adfc0..672161b9be 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -527,5 +527,10 @@ pub fn check_env_vars() -> bool { failed |= check_var::("ARCHON_URL"); + failed |= check_var::("MURALPAY_API_URL"); + failed |= check_var::("MURALPAY_API_KEY"); + failed |= check_var::("MURALPAY_TRANSFER_API_KEY"); + failed |= check_var::("MURALPAY_SOURCE_ACCOUNT_ID"); + failed } diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs index fc5dae83c4..24330525ac 100644 --- a/apps/labrinth/src/models/v3/payouts.rs +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -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, +} + +#[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, +} + +#[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 { 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, + #[serde(skip_serializing)] pub supported_countries: Vec, pub image_url: Option, pub image_logo_url: Option, @@ -136,6 +232,15 @@ pub struct PayoutMethodFee { pub max: Option, } +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); diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts/mod.rs similarity index 68% rename from apps/labrinth/src/queue/payouts.rs rename to apps/labrinth/src/queue/payouts/mod.rs index 88feaaea28..02244cc9d7 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -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>, payout_options: RwLock>, + pub muralpay: ArcSwapOption, +} + +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 { + 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::() + .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 { + let all_countries = rust_iso3166::ALL + .iter() + .map(|x| x.alpha2) + .collect::>(); + + 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, } 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, - pub currency_codes: Vec, - pub countries: Vec, - pub images: Vec, - } - - #[derive(Deserialize)] - pub struct TremendousResponse { - pub products: Vec, - } - - 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::>(); - 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 { + 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, +} + +impl PayoutFees { + pub fn total_fee(&self) -> Decimal { + self.method_fee + self.platform_fee + } +} + +async fn get_tremendous_payout_methods( + queue: &PayoutsQueue, +) -> Result> { + #[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, + currency_codes: Vec, + countries: Vec, + images: Vec, + } + + #[derive(Deserialize)] + struct TremendousResponse { + products: Vec, + } + + 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::>(); + 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#" diff --git a/apps/labrinth/src/queue/payouts/mural.rs b/apps/labrinth/src/queue/payouts/mural.rs new file mode 100644 index 0000000000..ef4739ab2f --- /dev/null +++ b/apps/labrinth/src/queue/payouts/mural.rs @@ -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 { + 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 { + 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> { + 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::(); + Ok(Some(AccountBalance { + available, + pending: Decimal::ZERO, + })) + } +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 32f5a0bf1c..8cca28c290 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -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), ); } diff --git a/apps/labrinth/src/routes/internal/mural.rs b/apps/labrinth/src/routes/internal/mural.rs new file mode 100644 index 0000000000..d10ff4e90c --- /dev/null +++ b/apps/labrinth/src/routes/internal/mural.rs @@ -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, +) -> Result, 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::>(); + let details = mural + .client + .get_bank_details(&fiat_and_rail_codes) + .await + .wrap_internal_err("failed to fetch bank details")?; + Ok(web::Json(details)) +} diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 66a20a91f0..ca55240b8e 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -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, } } diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 96c54ce40b..96c3aaf62d 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -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 { diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 8779a8874d..36a6b7fe1a 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -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, @@ -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, @@ -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, @@ -424,60 +419,55 @@ pub async fn tremendous_webhook( Ok(HttpResponse::NoContent().finish()) } -#[deprecated = "use `transaction_history` instead"] -pub async fn user_payouts( +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Withdrawal { + #[serde(with = "rust_decimal::serde::float")] + amount: Decimal, + #[serde(flatten)] + method: PayoutMethodRequest, + method_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WithdrawalFees { + pub fee: Decimal, + pub exchange_rate: Option, +} + +#[utoipa::path] +#[post("/fees")] +pub async fn calculate_fees( req: HttpRequest, pool: web::Data, redis: web::Data, + body: web::Json, session_queue: web::Data, -) -> Result>, ApiError> { - let (_, user) = get_user_from_headers( + payouts_queue: web::Data, +) -> Result, 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, - Scopes::PAYOUTS_READ, ) - .await?; + .await? + .ok_or_else(|| { + ApiError::Authentication(AuthenticationError::InvalidCredentials) + })?; - 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::>(); - Ok(web::Json(items)) -} + let fees = payouts_queue + .calculate_fees(&body.method, &body.method_id, body.amount) + .await?; -#[derive(Deserialize)] -pub struct Withdrawal { - #[serde(with = "rust_decimal::serde::float")] - amount: Decimal, - method: PayoutMethodType, - method_id: String, + 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, session_queue: web::Data, payouts_queue: web::Data, -) -> Result { +) -> 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_id = generate_payout_id(&mut transaction) + .await + .wrap_internal_err("failed to generate payout ID")?; + + 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, + }; - 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_item = match &body.method { + PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => { + paypal_payout(payout_cx).await? + } + PayoutMethodRequest::Tremendous { method_details } => { + tremendous_payout(payout_cx, method_details).await? + } + PayoutMethodRequest::MuralPay { method_details } => { + mural_pay_payout(payout_cx, method_details).await? + } + }; - ( - "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(), - )); - }; + payout_item + .insert(&mut transaction) + .await + .wrap_internal_err("failed to insert payout")?; - #[derive(Deserialize)] - struct PayPalLink { - href: String, - } + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) + .await + .wrap_internal_err("failed to clear user caches")?; - #[derive(Deserialize)] - struct PayoutsResponse { - pub links: Vec, - } + Ok(()) +} - 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?; +#[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, +} - if let Some(link) = res.links.first() { - #[derive(Deserialize)] - struct PayoutItem { - pub payout_item_id: String, - } +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" + ))); + } - #[derive(Deserialize)] - struct PayoutData { - pub items: Vec, - } + Ok(email) +} - 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()); - } - } +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 { + 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, + }; - payout_item - } - 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 Reward { + pub id: String, + } - #[derive(Deserialize)] - struct Order { - pub rewards: Vec, - } + #[derive(Deserialize)] + struct Order { + pub rewards: Vec, + } - #[derive(Deserialize)] - struct TremendousResponse { - pub order: Order, - } + #[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()) - } + 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) + }; - payout_item - } else { + 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 { + 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 { + 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( - "You must verify your account email to proceed!" + "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( - "You must add an email to your account to proceed!" - .to_string(), + "Please re-link your PayPal account!".to_string(), )); } - } - PayoutMethodType::Unknown => { - return Err(ApiError::Payments( - "Invalid payment method specified!".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, + } + + 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, }; - payout_item.insert(&mut transaction).await?; + 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?; - transaction.commit().await?; - crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) - .await?; + if let Some(link) = res.links.first() { + #[derive(Deserialize)] + struct PayoutItem { + pub payout_item_id: String, + } - Ok(HttpResponse::NoContent().finish()) + #[derive(Deserialize)] + struct PayoutData { + pub items: Vec, + } + + 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) } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// 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, method_address: Option, }, + /// User got a payout available for them to withdraw. PayoutAvailable { created: DateTime, 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)))] +#[get("/history")] pub async fn transaction_history( req: HttpRequest, pool: web::Data, @@ -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::() + .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, filter: web::Query, @@ -1079,7 +1212,8 @@ pub struct UserBalance { pub dates: HashMap, Decimal>, } -#[get("balance")] +#[utoipa::path] +#[get("/balance")] pub async fn get_balance( req: HttpRequest, pool: web::Data, @@ -1217,7 +1351,9 @@ async fn update_compliance_status( user_id: crate::database::models::ids::DBUserId, ) -> Result, 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, pool: web::Data, diff --git a/apps/labrinth/src/util/env.rs b/apps/labrinth/src/util/env.rs index 78a5b72dee..0bcb6bde6d 100644 --- a/apps/labrinth/src/util/env.rs +++ b/apps/labrinth/src/util/env.rs @@ -1,5 +1,12 @@ use std::str::FromStr; +use eyre::{Context, eyre}; + +pub fn env_var(key: &str) -> eyre::Result { + dotenvy::var(key) + .wrap_err_with(|| eyre!("missing environment variable `{key}`")) +} + pub fn parse_var(var: &str) -> Option { dotenvy::var(var).ok().and_then(|i| i.parse().ok()) } diff --git a/apps/labrinth/src/util/error.rs b/apps/labrinth/src/util/error.rs index fdc9ab3755..5f9ff343c2 100644 --- a/apps/labrinth/src/util/error.rs +++ b/apps/labrinth/src/util/error.rs @@ -5,111 +5,253 @@ use std::{ use crate::routes::ApiError; +/// Allows wrapping [`Result`]s and [`Option`]s into [`Result`]s. +#[allow( + clippy::missing_errors_doc, + reason = "this trait's purpose is improving error handling" +)] pub trait Context: Sized { - fn wrap_request_err_with( - self, - f: impl FnOnce() -> D, - ) -> Result + /// Maps the error variant into an [`eyre::Report`], creating the message + /// using `f`. + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result where - D: Debug + Display + Send + Sync + 'static; + D: Send + Sync + Debug + Display + 'static; - fn wrap_request_err(self, msg: D) -> Result + /// Maps the error variant into an [`eyre::Report`] with the given message. + #[inline] + fn wrap_err(self, msg: D) -> Result 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( self, f: impl FnOnce() -> D, ) -> Result 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(self, msg: D) -> Result where - D: Debug + Display + Send + Sync + 'static, + D: Send + Sync + Debug + Display + 'static, { self.wrap_internal_err_with(|| msg) } -} -impl Context for Result -where - E: std::error::Error + Send + Sync + Sized + 'static, -{ + /// Maps the error variant into an [`ApiError::Request`] using the closure to create the message. + #[inline] fn wrap_request_err_with( self, f: impl FnOnce() -> D, ) -> Result 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) - }) + self.wrap_err_with(f).map_err(ApiError::Request) } - fn wrap_internal_err_with( - self, - f: impl FnOnce() -> D, - ) -> Result + /// Maps the error variant into an [`ApiError::Request`] with the given message. + #[inline] + fn wrap_request_err(self, msg: D) -> Result 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::Internal(report) - }) + 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(self, f: impl FnOnce() -> D) -> Result + 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(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_auth_err_with(|| msg) } } -impl Context for Option { - fn wrap_request_err_with( - self, - f: impl FnOnce() -> D, - ) -> Result +impl Context for Result +where + Self: eyre::WrapErr, +{ + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result where - D: Debug + Display + Send + Sync + 'static, + D: Send + Sync + Debug + Display + 'static, { - self.ok_or_else(|| ApiError::Request(eyre::Report::msg(f()))) + eyre::WrapErr::wrap_err_with(self, f) } +} - fn wrap_internal_err_with( - self, - f: impl FnOnce() -> D, - ) -> Result +impl Context for Option { + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result where - D: Debug + Display + Send + Sync + 'static, + D: Send + Sync + Debug + Display + '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}; + + #[test] + 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(); - fn sqlx_result() -> Result<(), sqlx::Error> { - Err(sqlx::Error::RowNotFound) + 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 = Ok(42); + let wrapped = result.wrap_err("context message"); + assert_eq!(wrapped.unwrap(), 42); + + let result: Result = 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 = Some(42); + let wrapped = option.wrap_err("context message"); + assert_eq!(wrapped.unwrap(), 42); + + let option: Option = None; + let wrapped = option.wrap_err("context message"); + assert!(wrapped.is_err()); + assert_eq!(wrapped.unwrap_err().to_string(), "context message"); } - // 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")?; + #[test] + fn test_context_trait_internal_error() { + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = result.wrap_internal_err("internal error context"); - None::<()>.wrap_internal_err("something is missing")?; + assert!(wrapped.is_err()); + match wrapped.unwrap_err() { + ApiError::Internal(report) => { + assert!(report.to_string().contains("internal error context")); + } + _ => panic!("Expected Internal error"), + } + } - Ok(()) + #[test] + fn test_context_trait_request_error() { + let result: Result = 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"), + } } - // just so we don't get a dead code warning #[test] - fn test_propagating() { - _ = propagating(); + fn test_context_trait_auth_error() { + let result: Result = 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 = 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") + ); } } diff --git a/packages/muralpay/Cargo.toml b/packages/muralpay/Cargo.toml new file mode 100644 index 0000000000..91d61c25a3 --- /dev/null +++ b/packages/muralpay/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "muralpay" +version = "0.1.0" +edition.workspace = true +description = "Mural Pay API" +repository = "https://github.com/modrinth/code/" +license = "MIT" +keywords = [] +categories = ["api-bindings"] + +[dependencies] +bytes = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +derive_more = { workspace = true, features = [ + "deref", + "display", + "error", + "from", +] } +reqwest = { workspace = true, features = ["default-tls", "http2", "json"] } +rust_decimal = { workspace = true, features = ["macros"] } +rust_iso3166 = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true, features = ["derive"] } +utoipa = { workspace = true, features = ["uuid"], optional = true } +uuid = { workspace = true, features = ["serde"] } + +[dev-dependencies] +clap = { workspace = true, features = ["derive"] } +color-eyre = { workspace = true } +dotenvy = { workspace = true } +eyre = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing-subscriber = { workspace = true } + +[features] +utoipa = ["dep:utoipa"] + +[lints] +workspace = true diff --git a/packages/muralpay/README.md b/packages/muralpay/README.md new file mode 100644 index 0000000000..5413dcf5ff --- /dev/null +++ b/packages/muralpay/README.md @@ -0,0 +1,5 @@ +Rust API bindings for the [Mural Pay API](https://developers.muralpay.com/docs/getting-started). + +# Useful links + +- [Mural Pay API Reference](https://developers.muralpay.com/reference/) diff --git a/packages/muralpay/examples/muralpay.rs b/packages/muralpay/examples/muralpay.rs new file mode 100644 index 0000000000..d3ae1afba5 --- /dev/null +++ b/packages/muralpay/examples/muralpay.rs @@ -0,0 +1,321 @@ +use std::{env, fmt::Debug, io}; + +use eyre::{Result, WrapErr, eyre}; +use muralpay::{ + AccountId, CounterpartyId, CreatePayout, CreatePayoutDetails, Dob, + FiatAccountType, FiatAndRailCode, FiatAndRailDetails, FiatFeeRequest, + FiatPayoutFee, MuralPay, PayoutMethodId, PayoutRecipientInfo, + PhysicalAddress, TokenAmount, TokenFeeRequest, TokenPayoutFee, UsdSymbol, +}; +use rust_decimal::{Decimal, dec}; +use serde::Serialize; + +#[derive(Debug, clap::Parser)] +struct Args { + #[arg(short, long)] + output: Option, + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, clap::Subcommand)] +enum Command { + /// Account listing and management + Account { + #[command(subcommand)] + command: AccountCommand, + }, + /// Payouts and payout requests + Payout { + #[command(subcommand)] + command: PayoutCommand, + }, + /// Counterparty management + Counterparty { + #[command(subcommand)] + command: CounterpartyCommand, + }, + /// Payout method management + PayoutMethod { + #[command(subcommand)] + command: PayoutMethodCommand, + }, +} + +#[derive(Debug, clap::Subcommand)] +enum AccountCommand { + /// List all accounts + #[clap(alias = "ls")] + List, +} + +#[derive(Debug, clap::Subcommand)] +enum PayoutCommand { + /// List all payout requests + #[clap(alias = "ls")] + List, + /// Create a payout request + Create { + /// ID of the Mural account to send from + source_account_id: AccountId, + /// Description for this payout request + memo: Option, + }, + /// Get fees for a transaction + Fees { + #[command(subcommand)] + command: PayoutFeesCommand, + }, + /// Get bank details for a fiat and rail code + BankDetails { + /// Fiat and rail code to fetch bank details for + fiat_and_rail_code: FiatAndRailCode, + }, +} + +#[derive(Debug, clap::Subcommand)] +enum PayoutFeesCommand { + /// Get fees for a token-to-fiat transaction + Token { + amount: Decimal, + fiat_and_rail_code: FiatAndRailCode, + }, + /// Get fees for a fiat-to-token transaction + Fiat { + amount: Decimal, + fiat_and_rail_code: FiatAndRailCode, + }, +} + +#[derive(Debug, clap::Subcommand)] +enum CounterpartyCommand { + /// List all counterparties + #[clap(alias = "ls")] + List, +} + +#[derive(Debug, clap::Subcommand)] +enum PayoutMethodCommand { + /// List payout methods for a counterparty + #[clap(alias = "ls")] + List { + /// ID of the counterparty + counterparty_id: CounterpartyId, + }, + /// Delete a payout method + Delete { + /// ID of the counterparty + counterparty_id: CounterpartyId, + /// ID of the payout method to delete + payout_method_id: PayoutMethodId, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum OutputFormat { + Json, + JsonMin, +} + +#[tokio::main] +async fn main() -> Result<()> { + _ = dotenvy::dotenv(); + color_eyre::install().expect("failed to install `color-eyre`"); + tracing_subscriber::fmt().init(); + + let args = ::parse(); + let of = args.output; + + let api_url = env::var("MURALPAY_API_URL") + .unwrap_or_else(|_| muralpay::SANDBOX_API_URL.to_string()); + let api_key = env::var("MURALPAY_API_KEY").wrap_err("no API key")?; + let transfer_api_key = env::var("MURALPAY_TRANSFER_API_KEY").ok(); + + let muralpay = MuralPay::new(api_url, api_key, transfer_api_key); + + match args.command { + Command::Account { + command: AccountCommand::List, + } => run(of, muralpay.get_all_accounts().await?), + Command::Payout { + command: PayoutCommand::List, + } => run(of, muralpay.search_payout_requests(None, None).await?), + Command::Payout { + command: + PayoutCommand::Create { + source_account_id, + memo, + }, + } => run( + of, + create_payout_request( + &muralpay, + source_account_id, + memo.as_deref(), + ) + .await?, + ), + Command::Payout { + command: + PayoutCommand::Fees { + command: + PayoutFeesCommand::Token { + amount, + fiat_and_rail_code, + }, + }, + } => run( + of, + get_fees_for_token_amount(&muralpay, amount, fiat_and_rail_code) + .await?, + ), + Command::Payout { + command: + PayoutCommand::Fees { + command: + PayoutFeesCommand::Fiat { + amount, + fiat_and_rail_code, + }, + }, + } => run( + of, + get_fees_for_fiat_amount(&muralpay, amount, fiat_and_rail_code) + .await?, + ), + Command::Payout { + command: PayoutCommand::BankDetails { fiat_and_rail_code }, + } => run(of, muralpay.get_bank_details(&[fiat_and_rail_code]).await?), + Command::Counterparty { + command: CounterpartyCommand::List, + } => run(of, list_counterparties(&muralpay).await?), + Command::PayoutMethod { + command: PayoutMethodCommand::List { counterparty_id }, + } => run( + of, + muralpay + .search_payout_methods(counterparty_id, None) + .await?, + ), + Command::PayoutMethod { + command: + PayoutMethodCommand::Delete { + counterparty_id, + payout_method_id, + }, + } => run( + of, + muralpay + .delete_payout_method(counterparty_id, payout_method_id) + .await?, + ), + } + + Ok(()) +} + +async fn create_payout_request( + muralpay: &MuralPay, + source_account_id: AccountId, + memo: Option<&str>, +) -> Result<()> { + muralpay + .create_payout_request( + source_account_id, + memo, + &[CreatePayout { + amount: TokenAmount { + token_amount: dec!(2.00), + token_symbol: muralpay::USDC.into(), + }, + payout_details: CreatePayoutDetails::Fiat { + bank_name: "Foo Bank".into(), + bank_account_owner: "John Smith".into(), + developer_fee: None, + fiat_and_rail_details: FiatAndRailDetails::Usd { + symbol: UsdSymbol::Usd, + account_type: FiatAccountType::Checking, + bank_account_number: "123456789".into(), + // idk what the format is, https://wise.com/us/routing-number/bank/us-bank + bank_routing_number: "071004200".into(), + }, + }, + recipient_info: PayoutRecipientInfo::Individual { + first_name: "John".into(), + last_name: "Smith".into(), + email: "john.smith@example.com".into(), + date_of_birth: Dob::new(1970, 1, 1).unwrap(), + physical_address: PhysicalAddress { + address1: "1234 Elm Street".into(), + address2: Some("Apt 56B".into()), + country: rust_iso3166::US, + state: "CA".into(), + city: "Springfield".into(), + zip: "90001".into(), + }, + }, + supporting_details: None, + }], + ) + .await?; + Ok(()) +} + +async fn get_fees_for_token_amount( + muralpay: &MuralPay, + amount: Decimal, + fiat_and_rail_code: FiatAndRailCode, +) -> Result { + let fees = muralpay + .get_fees_for_token_amount(&[TokenFeeRequest { + amount: TokenAmount { + token_amount: amount, + token_symbol: muralpay::USDC.into(), + }, + fiat_and_rail_code, + }]) + .await?; + let fee = fees + .into_iter() + .next() + .ok_or_else(|| eyre!("no fee results returned"))?; + Ok(fee) +} + +async fn get_fees_for_fiat_amount( + muralpay: &MuralPay, + amount: Decimal, + fiat_and_rail_code: FiatAndRailCode, +) -> Result { + let fees = muralpay + .get_fees_for_fiat_amount(&[FiatFeeRequest { + fiat_amount: amount, + token_symbol: muralpay::USDC.into(), + fiat_and_rail_code, + }]) + .await?; + let fee = fees + .into_iter() + .next() + .ok_or_else(|| eyre!("no fee results returned"))?; + Ok(fee) +} + +async fn list_counterparties(muralpay: &MuralPay) -> Result<()> { + let _counterparties = muralpay.search_counterparties(None).await?; + Ok(()) +} + +fn run(output_format: Option, value: T) { + match output_format { + None => { + println!("{value:#?}"); + } + Some(OutputFormat::Json) => { + _ = serde_json::to_writer_pretty(io::stdout(), &value) + } + Some(OutputFormat::JsonMin) => { + _ = serde_json::to_writer(io::stdout(), &value); + } + } +} diff --git a/packages/muralpay/src/account.rs b/packages/muralpay/src/account.rs new file mode 100644 index 0000000000..4ec40fcac2 --- /dev/null +++ b/packages/muralpay/src/account.rs @@ -0,0 +1,236 @@ +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display}; +use rust_decimal::Decimal; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + Blockchain, FiatAmount, MuralError, MuralPay, TokenAmount, WalletDetails, + util::RequestExt, +}; + +impl MuralPay { + pub async fn get_all_accounts(&self) -> Result, MuralError> { + self.http_get(|base| format!("{base}/api/accounts")) + .send_mural() + .await + } + + pub async fn get_account( + &self, + id: AccountId, + ) -> Result { + self.http_get(|base| format!("{base}/api/accounts/{id}")) + .send_mural() + .await + } + + pub async fn create_account( + &self, + name: impl AsRef, + description: Option>, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + name: &'a str, + description: Option<&'a str>, + } + + let body = Body { + name: name.as_ref(), + description: description.as_ref().map(|x| x.as_ref()), + }; + + self.http + .post(format!("{}/api/accounts", self.api_url)) + .bearer_auth(self.api_key.expose_secret()) + .json(&body) + .send_mural() + .await + } +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct AccountId(pub Uuid); + +impl FromStr for AccountId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub id: AccountId, + pub name: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub is_api_enabled: bool, + pub status: AccountStatus, + pub account_details: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AccountStatus { + Initializing, + Active, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct AccountDetails { + pub wallet_details: WalletDetails, + pub balances: Vec, + pub payin_methods: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PayinMethod { + pub status: PayinMethodStatus, + pub supported_destination_tokens: Vec, + pub payin_rail_details: PayinRailDetails, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayinMethodStatus { + Activated, + Deactivated, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DestinationToken { + pub fees: Fees, + pub token: Token, + pub transaction_minimum: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Fees { + #[serde(with = "rust_decimal::serde::float")] + pub variable_fee_percentage: Decimal, + pub fixed_transaction_fee: Option, + #[serde(with = "rust_decimal::serde::float_option", default)] + pub developer_fee_percentage: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Token { + pub symbol: String, + pub blockchain: Blockchain, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayinRailDetails { + #[serde(rename_all = "camelCase")] + Usd { + currency: UsdCurrency, + payin_rails: Vec, + bank_beneficiary_name: String, + bank_beneficiary_address: String, + bank_name: String, + bank_address: String, + bank_routing_number: String, + bank_account_number: String, + }, + #[serde(rename_all = "camelCase")] + Eur { + currency: EurCurrency, + payin_rail: EurPayinRail, + bank_name: String, + bank_address: String, + account_holder_name: String, + iban: String, + bic: String, + }, + #[serde(rename_all = "camelCase")] + Cop { + currency: CopCurrency, + payin_rail: CopPayinRail, + }, + #[serde(rename_all = "camelCase")] + BlockchainDeposit { + deposit_token: DepositToken, + sender_address: Option, + destination_address: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum UsdCurrency { + Usd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum EurCurrency { + Eur, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum EurPayinRail { + Sepa, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum CopCurrency { + Cop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum CopPayinRail { + Pse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DepositToken { + #[serde(rename_all = "camelCase")] + UsdtTron { contract_address: String }, +} diff --git a/packages/muralpay/src/counterparty.rs b/packages/muralpay/src/counterparty.rs new file mode 100644 index 0000000000..84c20e4755 --- /dev/null +++ b/packages/muralpay/src/counterparty.rs @@ -0,0 +1,169 @@ +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use uuid::Uuid; + +use crate::{ + MuralError, MuralPay, PhysicalAddress, SearchParams, SearchResponse, + util::RequestExt, +}; + +impl MuralPay { + pub async fn search_counterparties( + &self, + params: Option>, + ) -> Result, MuralError> { + self.http_post(|base| format!("{base}/api/counterparties/search")) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .send_mural() + .await + } + + pub async fn get_counterparty( + &self, + id: CounterpartyId, + ) -> Result { + self.http_get(|base| { + format!("{base}/api/counterparties/counterparty/{id}") + }) + .send_mural() + .await + } + + pub async fn create_counterparty( + &self, + counterparty: &CreateCounterparty, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + counterparty: &'a CreateCounterparty, + } + + let body = Body { counterparty }; + + self.http_post(|base| format!("{base}/api/counterparties")) + .json(&body) + .send_mural() + .await + } + + pub async fn update_counterparty( + &self, + id: CounterpartyId, + counterparty: &UpdateCounterparty, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + counterparty: &'a UpdateCounterparty, + } + + let body = Body { counterparty }; + + self.http_put(|base| { + format!("{base}/api/counterparties/counterparty/{id}") + }) + .json(&body) + .send_mural() + .await + } +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct CounterpartyId(pub Uuid); + +impl FromStr for CounterpartyId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Counterparty { + pub id: CounterpartyId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub alias: Option, + #[serde(flatten)] + pub kind: CounterpartyKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CounterpartyKind { + #[serde(rename_all = "camelCase")] + Individual { + first_name: String, + last_name: String, + email: String, + physical_address: PhysicalAddress, + }, + #[serde(rename_all = "camelCase")] + Business { + name: String, + email: String, + physical_address: PhysicalAddress, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CreateCounterparty { + #[serde(rename_all = "camelCase")] + Individual { + alias: Option, + first_name: String, + last_name: String, + email: String, + physical_address: PhysicalAddress, + }, + #[serde(rename_all = "camelCase")] + Business { + alias: Option, + name: String, + email: String, + physical_address: PhysicalAddress, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum UpdateCounterparty { + #[serde(rename_all = "camelCase")] + Individual { + alias: Option, + first_name: Option, + last_name: Option, + email: Option, + physical_address: Option, + }, + #[serde(rename_all = "camelCase")] + Business { + alias: Option, + name: Option, + email: Option, + physical_address: Option, + }, +} diff --git a/packages/muralpay/src/error.rs b/packages/muralpay/src/error.rs new file mode 100644 index 0000000000..e7d9790a5e --- /dev/null +++ b/packages/muralpay/src/error.rs @@ -0,0 +1,117 @@ +use std::{collections::HashMap, fmt}; + +use bytes::Bytes; +use derive_more::{Display, Error, From}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Display, Error, From)] +pub enum MuralError { + #[display("API error")] + Api(ApiError), + #[display("request error")] + Request(reqwest::Error), + #[display("failed to decode response\n{json:?}")] + #[from(skip)] + Decode { + source: serde_json::Error, + json: Bytes, + }, + #[display("failed to decode error response\n{json:?}")] + #[from(skip)] + DecodeError { + source: serde_json::Error, + json: Bytes, + }, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Display, Error, From)] +pub enum TransferError { + #[display("no transfer API key")] + NoTransferKey, + #[display("API error")] + Api(Box), + #[display("request error")] + Request(reqwest::Error), + #[display("failed to decode response\n{json:?}")] + #[from(skip)] + Decode { + source: serde_json::Error, + json: Bytes, + }, + #[display("failed to decode error response\n{json:?}")] + #[from(skip)] + DecodeError { + source: serde_json::Error, + json: Bytes, + }, +} + +impl From for TransferError { + fn from(value: MuralError) -> Self { + match value { + MuralError::Api(x) => Self::Api(Box::new(x)), + MuralError::Request(x) => Self::Request(x), + MuralError::Decode { source, json } => { + Self::Decode { source, json } + } + MuralError::DecodeError { source, json } => { + Self::DecodeError { source, json } + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[serde(rename_all = "camelCase")] +pub struct ApiError { + pub error_instance_id: Uuid, + pub name: String, + pub message: String, + #[serde(deserialize_with = "one_or_many")] + #[serde(default)] + pub details: Vec, + #[serde(default)] + pub params: HashMap, +} + +fn one_or_many<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + + match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(s) => Ok(vec![s]), + OneOrMany::Many(v) => Ok(v), + } +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut lines = vec![self.message.to_string()]; + + if !self.details.is_empty() { + lines.push("details:".into()); + lines.extend(self.details.iter().map(|s| format!("- {s}"))); + } + + if !self.params.is_empty() { + lines.push("params:".into()); + lines + .extend(self.params.iter().map(|(k, v)| format!("- {k}: {v}"))); + } + + lines.push(format!("error name: {}", self.name)); + lines.push(format!("error instance id: {}", self.error_instance_id)); + + write!(f, "{}", lines.join("\n")) + } +} diff --git a/packages/muralpay/src/lib.rs b/packages/muralpay/src/lib.rs index b752525907..8f8fd398b0 100644 --- a/packages/muralpay/src/lib.rs +++ b/packages/muralpay/src/lib.rs @@ -1,22 +1,31 @@ #![doc = include_str!("../README.md")] mod account; +mod counterparty; mod error; mod organization; mod payout; +mod payout_method; +mod serde_iso3166; mod util; -pub use {account::*, error::*, organization::*, payout::*}; +pub use { + account::*, counterparty::*, error::*, organization::*, payout::*, + payout_method::*, +}; use rust_decimal::Decimal; use secrecy::SecretString; use serde::{Deserialize, Serialize}; -use std::ops::Deref; +use std::{ops::Deref, str::FromStr}; use uuid::Uuid; pub const API_URL: &str = "https://api.muralpay.com"; pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com"; +/// Default token symbol for [`TokenAmount::token_symbol`] values. +pub const USDC: &str = "USDC"; + #[derive(Debug)] pub struct MuralPay { pub http: reqwest::Client, @@ -41,6 +50,7 @@ impl MuralPay { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Blockchain { Ethereum, @@ -49,7 +59,10 @@ pub enum Blockchain { Celo, } +crate::util::display_as_serialize!(Blockchain); + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "SCREAMING-KEBAB-CASE")] pub enum CurrencyCode { Usd, @@ -65,7 +78,20 @@ pub enum CurrencyCode { Zar, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +crate::util::display_as_serialize!(CurrencyCode); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum FiatAccountType { + Checking, + Savings, +} + +crate::util::display_as_serialize!(FiatAccountType); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::EnumIter)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "kebab-case")] pub enum FiatAndRailCode { Usd, @@ -84,7 +110,18 @@ pub enum FiatAndRailCode { UsdPanama, } +crate::util::display_as_serialize!(FiatAndRailCode); + +impl FromStr for FiatAndRailCode { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_owned())) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct WalletDetails { pub blockchain: Blockchain, @@ -92,15 +129,19 @@ pub struct WalletDetails { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct TokenAmount { + #[serde(with = "rust_decimal::serde::float")] pub token_amount: Decimal, pub token_symbol: String, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct FiatAmount { + #[serde(with = "rust_decimal::serde::float")] pub fiat_amount: Decimal, pub fiat_currency_code: CurrencyCode, } @@ -126,6 +167,7 @@ impl + Clone> SearchParams { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct SearchResponse { pub total: u64, diff --git a/packages/muralpay/src/organization.rs b/packages/muralpay/src/organization.rs new file mode 100644 index 0000000000..811aca7bcf --- /dev/null +++ b/packages/muralpay/src/organization.rs @@ -0,0 +1,277 @@ +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display}; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + CurrencyCode, MuralError, MuralPay, SearchResponse, util::RequestExt, +}; + +impl MuralPay { + pub async fn search_organizations( + &self, + req: SearchRequest, + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body { + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option, + } + + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Filter { + #[serde(rename = "type")] + ty: FilterType, + name: String, + } + + #[derive(Debug, Clone, Copy, Serialize)] + #[serde(rename_all = "snake_case")] + pub enum FilterType { + Name, + } + + let query = [ + req.limit.map(|limit| ("limit", limit.to_string())), + req.next_id + .map(|next_id| ("nextId", next_id.hyphenated().to_string())), + ] + .into_iter() + .flatten() + .collect::>(); + + let body = Body { + filter: req.name.map(|name| Filter { + ty: FilterType::Name, + name, + }), + }; + + self.http_post(|base| format!("{base}/api/organizations/search")) + .bearer_auth(self.api_key.expose_secret()) + .query(&query) + .json(&body) + .send_mural() + .await + } + + pub async fn get_organization( + &self, + id: OrganizationId, + ) -> Result { + self.http_post(|base| format!("{base}/api/organizations/{id}")) + .send_mural() + .await + } +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct OrganizationId(pub Uuid); + +impl FromStr for OrganizationId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SearchRequest { + pub limit: Option, + pub next_id: Option, + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Organization { + Individual(Individual), + Business(Business), + EndUserCustodialIndividual(EndUserCustodialIndividual), + EndUserCustodialBusiness(EndUserCustodialBusiness), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Individual { + pub id: OrganizationId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub first_name: String, + pub last_name: String, + pub tos_status: TosStatus, + pub kyc_status: KycStatus, + pub currency_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Business { + pub id: OrganizationId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub name: String, + pub tos_status: TosStatus, + pub kyc_status: KycStatus, + pub currency_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct EndUserCustodialIndividual { + pub id: OrganizationId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub first_name: String, + pub last_name: String, + pub approver: Approver, + pub tos_status: TosStatus, + pub kyc_status: KycStatus, + pub currency_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct EndUserCustodialBusiness { + pub id: OrganizationId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub name: String, + pub approver: Approver, + pub tos_status: TosStatus, + pub kyc_status: KycStatus, + pub currency_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Approver { + pub id: Uuid, + pub created_at: DateTime, + pub name: String, + pub email: String, + pub auth_methods: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TosStatus { + NotAccepted, + NeedsReview, + Accepted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum KycStatus { + Inactive, + Pending, + Approved { + approved_at: DateTime, + }, + Errored { + details: String, + errored_at: DateTime, + }, + Rejected { + reason: String, + rejected_at: DateTime, + }, + PreValidationFailed { + failed_validation_reason: FailedValidationReason, + failed_validation_at: DateTime, + }, + NeedsUpdate { + needs_update_reason: String, + verification_status_updated_at: DateTime, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FailedValidationReason { + DocumentPrevalidationFailed { + document_id: String, + failed_validation_reason: String, + }, + UltimateBeneficialOwnerPrevalidationFailed { + ultimate_beneficial_owner_id: String, + failed_validation_reason: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CurrencyCapability { + pub fiat_and_rail_code: String, + pub currency_code: CurrencyCode, + pub deposit_status: TransactionCapabilityStatus, + pub pay_out_status: TransactionCapabilityStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TransactionCapabilityStatus { + TermsOfService { + details: String, + }, + #[serde(rename = "awaitingKYC")] + AwaitingKyc { + details: String, + }, + Enabled, + Rejected { + reason: RejectedReason, + details: String, + }, + Disabled { + reason: DisabledReason, + details: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RejectedReason { + KycFailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DisabledReason { + CapabilityUnavailable, + ProcessingError, +} diff --git a/packages/muralpay/src/payout.rs b/packages/muralpay/src/payout.rs new file mode 100644 index 0000000000..063035def6 --- /dev/null +++ b/packages/muralpay/src/payout.rs @@ -0,0 +1,825 @@ +#![cfg_attr( + feature = "utoipa", + expect( + clippy::large_stack_arrays, + reason = "due to `utoipa::ToSchema` derive" + ) +)] + +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display, Error, From}; +use rust_decimal::Decimal; +use rust_iso3166::CountryCode; +use serde::{Deserialize, Serialize}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use uuid::Uuid; + +use crate::{ + AccountId, Blockchain, FiatAccountType, FiatAmount, FiatAndRailCode, + MuralError, MuralPay, SearchParams, SearchResponse, TokenAmount, + TransferError, WalletDetails, util::RequestExt, +}; + +impl MuralPay { + pub async fn search_payout_requests( + &self, + filter: Option, + params: Option>, + ) -> Result, MuralError> + { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body { + filter: Option, + } + + let body = Body { filter }; + + self.http_post(|base| format!("{base}/api/payouts/search")) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .json(&body) + .send_mural() + .await + } + + pub async fn get_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + self.http_get(|base| format!("{base}/api/payouts/{id}")) + .send_mural() + .await + } + + pub async fn get_fees_for_token_amount( + &self, + token_fee_requests: &[TokenFeeRequest], + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + token_fee_requests: &'a [TokenFeeRequest], + } + + let body = Body { token_fee_requests }; + + self.http_post(|base| format!("{base}/api/payouts/fees/token-to-fiat")) + .json(&body) + .send_mural() + .await + } + + pub async fn get_fees_for_fiat_amount( + &self, + fiat_fee_requests: &[FiatFeeRequest], + ) -> Result, MuralError> { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + fiat_fee_requests: &'a [FiatFeeRequest], + } + + let body = Body { fiat_fee_requests }; + + self.http_post(|base| format!("{base}/api/payouts/fees/fiat-to-token")) + .json(&body) + .send_mural() + .await + } + + pub async fn create_payout_request( + &self, + source_account_id: AccountId, + memo: Option>, + payouts: &[CreatePayout], + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + source_account_id: AccountId, + memo: Option<&'a str>, + payouts: &'a [CreatePayout], + } + + let body = Body { + source_account_id, + memo: memo.as_ref().map(|x| x.as_ref()), + payouts, + }; + + self.http_post(|base| format!("{base}/api/payouts/payout")) + .json(&body) + .send_mural() + .await + } + + pub async fn execute_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + self.http_post(|base| format!("{base}/api/payouts/payout/{id}/execute")) + .transfer_auth(self)? + .send_mural() + .await + .map_err(From::from) + } + + pub async fn cancel_payout_request( + &self, + id: PayoutRequestId, + ) -> Result { + self.http_post(|base| format!("{base}/api/payouts/payout/{id}/cancel")) + .transfer_auth(self)? + .send_mural() + .await + .map_err(From::from) + } + + pub async fn get_bank_details( + &self, + fiat_currency_and_rail: &[FiatAndRailCode], + ) -> Result { + let query = fiat_currency_and_rail + .iter() + .map(|code| ("fiatCurrencyAndRail", code.to_string())) + .collect::>(); + + self.http_get(|base| format!("{base}/api/payouts/bank-details")) + .query(&query) + .send_mural() + .await + } +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct PayoutRequestId(pub Uuid); + +impl FromStr for PayoutRequestId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct PayoutId(pub Uuid); + +impl FromStr for PayoutId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayoutStatusFilter { + PayoutStatus { statuses: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PayoutRequest { + pub id: PayoutRequestId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub source_account_id: AccountId, + pub transaction_hash: Option, + pub memo: Option, + pub status: PayoutStatus, + pub payouts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayoutStatus { + AwaitingExecution, + Canceled, + Pending, + Executed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct Payout { + pub id: PayoutId, + pub created_at: DateTime, + pub updated_at: DateTime, + pub amount: TokenAmount, + pub details: PayoutDetails, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayoutDetails { + Fiat(FiatPayoutDetails), + Blockchain(BlockchainPayoutDetails), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct FiatPayoutDetails { + pub fiat_and_rail_code: FiatAndRailCode, + pub fiat_payout_status: FiatPayoutStatus, + pub fiat_amount: FiatAmount, + pub transaction_fee: TokenAmount, + #[serde(with = "rust_decimal::serde::float")] + pub exchange_fee_percentage: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub exchange_rate: Decimal, + pub fee_total: TokenAmount, + pub developer_fee: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum FiatPayoutStatus { + Created, + #[serde(rename_all = "camelCase")] + Pending { + initiated_at: DateTime, + }, + #[serde(rename_all = "camelCase")] + OnHold { + initiated_at: DateTime, + }, + #[serde(rename_all = "camelCase")] + Completed { + initiated_at: DateTime, + completed_at: DateTime, + }, + #[serde(rename_all = "camelCase")] + Failed { + initiated_at: DateTime, + reason: String, + error_code: FiatPayoutErrorCode, + }, + Canceled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum FiatPayoutErrorCode { + Unknown, + AccountNumberIncorrect, + RejectedByBank, + AccountTypeIncorrect, + AccountClosed, + BeneficiaryDocumentationIncorrect, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DeveloperFee { + #[serde(with = "rust_decimal::serde::float_option", default)] + pub developer_fee_percentage: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct BlockchainPayoutDetails { + pub wallet_address: String, + pub blockchain: Blockchain, + pub status: BlockchainPayoutStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum BlockchainPayoutStatus { + AwaitingExecution, + Pending, + Executed, + Failed, + Canceled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreatePayout { + pub amount: TokenAmount, + pub payout_details: CreatePayoutDetails, + pub recipient_info: PayoutRecipientInfo, + pub supporting_details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CreatePayoutDetails { + #[serde(rename_all = "camelCase")] + Fiat { + bank_name: String, + bank_account_owner: String, + developer_fee: Option, + fiat_and_rail_details: FiatAndRailDetails, + }, + #[serde(rename_all = "camelCase")] + Blockchain { wallet_details: WalletDetails }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum FiatAndRailDetails { + #[serde(rename_all = "camelCase")] + Usd { + symbol: UsdSymbol, + account_type: FiatAccountType, + bank_account_number: String, + bank_routing_number: String, + }, + #[serde(rename_all = "camelCase")] + Cop { + symbol: CopSymbol, + phone_number: String, + account_type: FiatAccountType, + bank_account_number: String, + document_number: String, + document_type: DocumentType, + }, + #[serde(rename_all = "camelCase")] + Ars { + symbol: ArsSymbol, + bank_account_number: String, + document_number: String, + bank_account_number_type: String, + }, + #[serde(rename_all = "camelCase")] + Eur { + symbol: EurSymbol, + iban: String, + swift_bic: String, + #[serde(with = "crate::serde_iso3166")] + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + country: CountryCode, + }, + #[serde(rename_all = "camelCase")] + Mxn { + symbol: MxnSymbol, + bank_account_number: String, + }, + #[serde(rename_all = "camelCase")] + Brl { + symbol: BrlSymbol, + pix_account_type: PixAccountType, + pix_email: String, + pix_phone: String, + branch_code: String, + document_number: String, + }, + #[serde(rename_all = "camelCase")] + Clp { + symbol: ClpSymbol, + account_type: FiatAccountType, + bank_account_number: String, + document_type: DocumentType, + document_number: String, + }, + #[serde(rename_all = "camelCase")] + Pen { + symbol: PenSymbol, + document_number: String, + document_type: DocumentType, + bank_account_number: String, + account_type: FiatAccountType, + }, + #[serde(rename_all = "camelCase")] + Bob { + symbol: BobSymbol, + bank_account_number: String, + document_number: String, + document_type: DocumentType, + }, + #[serde(rename_all = "camelCase")] + Crc { + symbol: CrcSymbol, + iban: String, + document_number: String, + document_type: DocumentType, + }, + #[serde(rename_all = "camelCase")] + Zar { + symbol: ZarSymbol, + account_type: FiatAccountType, + bank_account_number: String, + }, + #[serde(rename_all = "camelCase")] + UsdPeru { + symbol: UsdSymbol, + account_type: FiatAccountType, + bank_account_number: String, + document_number: String, + document_type: DocumentType, + }, + #[serde(rename_all = "camelCase")] + UsdChina { + symbol: UsdSymbol, + bank_name: String, + account_type: FiatAccountType, + bank_account_number: String, + document_number: String, + document_type: DocumentType, + phone_number: String, + address: String, + swift_bic: String, + }, +} + +impl FiatAndRailDetails { + pub fn code(&self) -> FiatAndRailCode { + match self { + Self::Usd { .. } => FiatAndRailCode::Usd, + Self::Cop { .. } => FiatAndRailCode::Cop, + Self::Ars { .. } => FiatAndRailCode::Ars, + Self::Eur { .. } => FiatAndRailCode::Eur, + Self::Mxn { .. } => FiatAndRailCode::Mxn, + Self::Brl { .. } => FiatAndRailCode::Brl, + Self::Clp { .. } => FiatAndRailCode::Clp, + Self::Pen { .. } => FiatAndRailCode::Pen, + Self::Bob { .. } => FiatAndRailCode::Bob, + Self::Crc { .. } => FiatAndRailCode::Crc, + Self::Zar { .. } => FiatAndRailCode::Zar, + Self::UsdPeru { .. } => FiatAndRailCode::UsdPeru, + Self::UsdChina { .. } => FiatAndRailCode::UsdChina, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum UsdSymbol { + Usd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum CopSymbol { + Cop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum ArsSymbol { + Ars, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum EurSymbol { + Eur, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum MxnSymbol { + Mxn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum BrlSymbol { + Brl, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum ClpSymbol { + Clp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum PenSymbol { + Pen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum BobSymbol { + Bob, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum CrcSymbol { + Crc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING-KEBAB-CASE")] +pub enum ZarSymbol { + Zar, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DocumentType { + NationalId, + Passport, + ResidentId, + Ruc, + TaxId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PixAccountType { + Phone, + Email, + Document, + BankAccount, +} + +#[derive(Debug, Clone, Serialize, Deserialize, From)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayoutRecipientInfo { + #[serde(rename_all = "camelCase")] + Individual { + first_name: String, + last_name: String, + email: String, + date_of_birth: Dob, + physical_address: PhysicalAddress, + }, + #[serde(rename_all = "camelCase")] + Business { + name: String, + email: String, + physical_address: PhysicalAddress, + }, +} + +#[derive(Debug, Display, Clone, Copy, SerializeDisplay, DeserializeFromStr)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{year:04}-{month:02}-{day:02}")] +pub struct Dob { + year: u16, + month: u8, + day: u8, +} + +#[derive(Debug, Display, Clone, Error)] +pub enum InvalidDob { + #[display("must be three segments separated by `-`")] + NotThreeSegments, + #[display("year is not an integer")] + YearNotInt, + #[display("month is not an integer")] + MonthNotInt, + #[display("day is not an integer")] + DayNotInt, + #[display("year out of range")] + YearRange, + #[display("month out of range")] + MonthRange, + #[display("day out of range")] + DayRange, +} + +impl Dob { + pub fn new(year: u16, month: u8, day: u8) -> Result { + if !(1000..10000).contains(&year) { + return Err(InvalidDob::YearRange); + } + if month > 12 { + return Err(InvalidDob::MonthRange); + } + if day > 31 { + return Err(InvalidDob::DayRange); + } + Ok(Self { year, month, day }) + } +} + +impl FromStr for Dob { + type Err = InvalidDob; + + fn from_str(s: &str) -> Result { + let [year, month, day] = s + .split('-') + .collect::>() + .try_into() + .map_err(|_| InvalidDob::NotThreeSegments)?; + let year = year.parse::().map_err(|_| InvalidDob::YearNotInt)?; + let month = month.parse::().map_err(|_| InvalidDob::MonthNotInt)?; + let day = day.parse::().map_err(|_| InvalidDob::DayNotInt)?; + Self::new(year, month, day) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PhysicalAddress { + pub address1: String, + pub address2: Option, + #[serde(with = "crate::serde_iso3166")] + #[cfg_attr(feature = "utoipa", schema(value_type = String))] + pub country: CountryCode, + pub state: String, + pub city: String, + pub zip: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SupportingDetails { + pub supporting_document: Option, // data:image/jpeg;base64,... + pub payout_purpose: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayoutPurpose { + VendorPayment, + Payroll, + TaxPayment, + RentLeasePayment, + SupplierPayment, + PersonalGift, + FamilySupport, + CharitableDonation, + ExpenseReimbursement, + BillUtilityPayment, + TravelExpenses, + InvestmentContribution, + CashWithdrawal, + RealEstatePurchase, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct TokenFeeRequest { + pub amount: TokenAmount, + pub fiat_and_rail_code: FiatAndRailCode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TokenPayoutFee { + #[serde(rename_all = "camelCase")] + Success { + #[serde(with = "rust_decimal::serde::float")] + exchange_rate: Decimal, + #[serde(with = "rust_decimal::serde::float")] + exchange_fee_percentage: Decimal, + fiat_and_rail_code: FiatAndRailCode, + transaction_fee: TokenAmount, + min_transaction_value: TokenAmount, + estimated_fiat_amount: FiatAmount, + token_amount: TokenAmount, + fee_total: TokenAmount, + }, + #[serde(rename_all = "camelCase")] + Error { + token_amount: TokenAmount, + message: String, + fiat_and_rail_code: FiatAndRailCode, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct FiatFeeRequest { + #[serde(with = "rust_decimal::serde::float")] + pub fiat_amount: Decimal, + pub token_symbol: String, + pub fiat_and_rail_code: FiatAndRailCode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FiatPayoutFee { + #[serde(rename_all = "camelCase")] + Success { + token_symbol: String, + fiat_amount: FiatAmount, + #[serde(with = "rust_decimal::serde::float")] + exchange_rate: Decimal, + #[serde(with = "rust_decimal::serde::float")] + exchange_fee_percentage: Decimal, + fiat_and_rail_code: FiatAndRailCode, + transaction_fee: TokenAmount, + min_transaction_value: TokenAmount, + estimated_token_amount_required: TokenAmount, + fee_total: TokenAmount, + }, + #[serde(rename_all = "camelCase")] + Error { + message: String, + fiat_and_rail_code: FiatAndRailCode, + token_symbol: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct BankDetailsResponse { + pub bank_details: CurrenciesBankDetails, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "kebab-case")] +pub struct CurrenciesBankDetails { + #[serde(default)] + pub usd: CurrencyBankDetails, + #[serde(default)] + pub cop: CurrencyBankDetails, + #[serde(default)] + pub ars: CurrencyBankDetails, + #[serde(default)] + pub eur: CurrencyBankDetails, + #[serde(default)] + pub mxn: CurrencyBankDetails, + #[serde(default)] + pub brl: CurrencyBankDetails, + #[serde(default)] + pub clp: CurrencyBankDetails, + #[serde(default)] + pub pen: CurrencyBankDetails, + #[serde(default)] + pub bob: CurrencyBankDetails, + #[serde(default)] + pub crc: CurrencyBankDetails, + #[serde(default)] + pub zar: CurrencyBankDetails, + #[serde(default)] + pub usd_peru: CurrencyBankDetails, + #[serde(default)] + pub usd_china: CurrencyBankDetails, + #[serde(default)] + pub usd_panama: CurrencyBankDetails, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CurrencyBankDetails { + pub bank_names: Vec, +} diff --git a/packages/muralpay/src/payout_method.rs b/packages/muralpay/src/payout_method.rs new file mode 100644 index 0000000000..1a451b4c0a --- /dev/null +++ b/packages/muralpay/src/payout_method.rs @@ -0,0 +1,439 @@ +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use derive_more::{Deref, Display, Error}; +use serde::{Deserialize, Serialize}; +use serde_with::DeserializeFromStr; +use uuid::Uuid; + +use crate::{ + ArsSymbol, BobSymbol, BrlSymbol, ClpSymbol, CopSymbol, CounterpartyId, + CrcSymbol, DocumentType, EurSymbol, FiatAccountType, MuralError, MuralPay, + MxnSymbol, PenSymbol, SearchParams, SearchResponse, UsdSymbol, + WalletDetails, ZarSymbol, util::RequestExt, +}; + +impl MuralPay { + pub async fn search_payout_methods( + &self, + counterparty_id: CounterpartyId, + params: Option>, + ) -> Result, MuralError> { + self.http_post(|base| { + format!( + "{base}/api/counterparties/{counterparty_id}/payout-methods/search" + ) + }) + .query(¶ms.map(|p| p.to_query()).unwrap_or_default()) + .send_mural() + .await + } + + pub async fn get_payout_method( + &self, + counterparty_id: CounterpartyId, + payout_method_id: PayoutMethodId, + ) -> Result { + self.http_get(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}")) + .send_mural() + .await + } + + pub async fn create_payout_method( + &self, + counterparty_id: CounterpartyId, + alias: impl AsRef, + payout_method: &PayoutMethodDetails, + ) -> Result { + #[derive(Debug, Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + alias: &'a str, + payout_method: &'a PayoutMethodDetails, + } + + let body = Body { + alias: alias.as_ref(), + payout_method, + }; + + self.http_post(|base| { + format!( + "{base}/api/counterparties/{counterparty_id}/payout-methods" + ) + }) + .json(&body) + .send_mural() + .await + } + + pub async fn delete_payout_method( + &self, + counterparty_id: CounterpartyId, + payout_method_id: PayoutMethodId, + ) -> Result<(), MuralError> { + self.http_delete(|base| format!("{base}/api/counterparties/{counterparty_id}/payout-methods/{payout_method_id}")) + .send_mural() + .await + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayoutMethodDocumentType { + NationalId, + Passport, + ResidentId, + Ruc, + TaxId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayoutMethodPixAccountType { + Phone, + Email, + Document, + BankAccount, +} + +#[derive( + Debug, + Display, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Deref, + Serialize, + Deserialize, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[display("{}", _0.hyphenated())] +pub struct PayoutMethodId(pub Uuid); + +impl FromStr for PayoutMethodId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + s.parse::().map(Self) + } +} + +#[derive(Debug, Clone, Serialize, DeserializeFromStr)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct TruncatedString(String); + +const TRUNCATED_LEN: usize = 4; + +#[derive(Debug, Display, Error)] +#[display("expected {TRUNCATED_LEN} characters, got {num_chars}")] +pub struct InvalidTruncated { + pub num_chars: usize, +} + +impl FromStr for TruncatedString { + type Err = InvalidTruncated; + + fn from_str(s: &str) -> Result { + let num_chars = s.chars().count(); + if num_chars == TRUNCATED_LEN { + Ok(Self(s.to_string())) + } else { + Err(InvalidTruncated { num_chars }) + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct PayoutMethod { + pub id: PayoutMethodId, + pub created_at: DateTime, + pub counterparty_id: CounterpartyId, + pub alias: String, + pub payout_method: PayoutMethodDetails, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PayoutMethodDetails { + #[serde(rename_all = "camelCase")] + Usd { details: UsdPayoutDetails }, + #[serde(rename_all = "camelCase")] + Ars { details: ArsPayoutDetails }, + #[serde(rename_all = "camelCase")] + Brl { details: BrlPayoutDetails }, + #[serde(rename_all = "camelCase")] + Cop { details: CopPayoutDetails }, + #[serde(rename_all = "camelCase")] + Eur { details: EurPayoutDetails }, + #[serde(rename_all = "camelCase")] + Mxn { details: MxnPayoutDetails }, + #[serde(rename_all = "camelCase")] + Clp { details: ClpPayoutDetails }, + #[serde(rename_all = "camelCase")] + Pen { details: PenPayoutDetails }, + #[serde(rename_all = "camelCase")] + Bob { details: BobPayoutDetails }, + #[serde(rename_all = "camelCase")] + Crc { details: CrcPayoutDetails }, + #[serde(rename_all = "camelCase")] + Zar { details: ZarPayoutDetails }, + #[serde(rename_all = "camelCase")] + BlockchainWallet { details: WalletDetails }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum UsdPayoutDetails { + #[serde(rename_all = "camelCase")] + UsdDomestic { + symbol: UsdSymbol, + account_type: FiatAccountType, + transfer_type: UsdTransferType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + bank_routing_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + UsdPeru { + symbol: UsdSymbol, + account_type: FiatAccountType, + document_type: DocumentType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + UsdChina { + symbol: UsdSymbol, + account_type: FiatAccountType, + document_type: DocumentType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + swift_bic_truncated: TruncatedString, + phone_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + UsdPanama { + symbol: UsdSymbol, + account_type: FiatAccountType, + document_type: DocumentType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum UsdTransferType { + Ach, + Wire, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ArsPayoutDetails { + #[serde(rename_all = "camelCase")] + ArsAlias { + symbol: ArsSymbol, + bank_name: String, + alias_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + ArsAccountNumber { + symbol: ArsSymbol, + bank_account_number_type: ArsBankAccountNumberType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ArsBankAccountNumberType { + Cvu, + Cbu, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum BrlPayoutDetails { + #[serde(rename_all = "camelCase")] + PixPhone { + symbol: BrlSymbol, + full_legal_name: String, + bank_name: String, + phone_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + PixEmail { + symbol: BrlSymbol, + full_legal_name: String, + bank_name: String, + email_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + PixDocument { + symbol: BrlSymbol, + full_legal_name: String, + bank_name: String, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + PixBankAccount { + symbol: BrlSymbol, + full_legal_name: String, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, + #[serde(rename_all = "camelCase")] + Wire { + symbol: BrlSymbol, + account_type: FiatAccountType, + full_legal_name: String, + bank_name: String, + account_number_truncated: TruncatedString, + bank_branch_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CopPayoutDetails { + #[serde(rename_all = "camelCase")] + CopDomestic { + symbol: CopSymbol, + account_type: FiatAccountType, + document_type: DocumentType, + bank_name: String, + phone_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + bank_account_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum EurPayoutDetails { + #[serde(rename_all = "camelCase")] + EurSepa { + symbol: EurSymbol, + country: String, + bank_name: String, + iban_truncated: TruncatedString, + swift_bic_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MxnPayoutDetails { + #[serde(rename_all = "camelCase")] + MxnDomestic { + symbol: MxnSymbol, + bank_name: String, + bank_account_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ClpPayoutDetails { + #[serde(rename_all = "camelCase")] + ClpDomestic { + clp: ClpSymbol, + account_type: FiatAccountType, + document_type: DocumentType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PenPayoutDetails { + #[serde(rename_all = "camelCase")] + PenDomestic { + symbol: PenSymbol, + document_type: DocumentType, + account_type: FiatAccountType, + bank_name: String, + document_number_truncated: TruncatedString, + bank_account_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum BobPayoutDetails { + #[serde(rename_all = "camelCase")] + BobDomestic { + symbol: BobSymbol, + document_type: DocumentType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum CrcPayoutDetails { + #[serde(rename_all = "camelCase")] + CrcDomestic { + symbol: CrcSymbol, + document_type: DocumentType, + bank_name: String, + iban_truncated: TruncatedString, + document_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ZarPayoutDetails { + #[serde(rename_all = "camelCase")] + ZarDomestic { + symbol: ZarSymbol, + account_type: FiatAccountType, + bank_name: String, + bank_account_number_truncated: TruncatedString, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct CreatePayoutMethod { + pub alias: String, + pub payout_method: PayoutMethodDetails, +} diff --git a/packages/muralpay/src/serde_iso3166.rs b/packages/muralpay/src/serde_iso3166.rs new file mode 100644 index 0000000000..28c9e1987c --- /dev/null +++ b/packages/muralpay/src/serde_iso3166.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, de::Error}; +use std::borrow::Cow; + +use rust_iso3166::CountryCode; + +pub fn serialize( + v: &CountryCode, + serializer: S, +) -> Result { + serializer.serialize_str(v.alpha2) +} + +pub fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result { + >::deserialize(deserializer).and_then(|country_code| { + rust_iso3166::ALPHA2_MAP + .get(&country_code) + .copied() + .ok_or_else(|| { + D::Error::custom("invalid ISO 3166 alpha-2 country code") + }) + }) +} diff --git a/packages/muralpay/src/util.rs b/packages/muralpay/src/util.rs new file mode 100644 index 0000000000..709c3df40d --- /dev/null +++ b/packages/muralpay/src/util.rs @@ -0,0 +1,100 @@ +use reqwest::{IntoUrl, RequestBuilder}; +use secrecy::ExposeSecret; +use serde::de::DeserializeOwned; + +use crate::{ApiError, MuralError, MuralPay, TransferError}; + +impl MuralPay { + fn http_req( + &self, + make_req: impl FnOnce() -> RequestBuilder, + ) -> RequestBuilder { + make_req() + .bearer_auth(self.api_key.expose_secret()) + .header("accept", "application/json") + .header("content-type", "application/json") + } + + pub(crate) fn http_get( + &self, + make_url: impl FnOnce(&str) -> U, + ) -> RequestBuilder { + self.http_req(|| self.http.get(make_url(&self.api_url))) + } + + pub(crate) fn http_post( + &self, + make_url: impl FnOnce(&str) -> U, + ) -> RequestBuilder { + self.http_req(|| self.http.post(make_url(&self.api_url))) + } + + pub(crate) fn http_put( + &self, + make_url: impl FnOnce(&str) -> U, + ) -> RequestBuilder { + self.http_req(|| self.http.put(make_url(&self.api_url))) + } + + pub(crate) fn http_delete( + &self, + make_url: impl FnOnce(&str) -> U, + ) -> RequestBuilder { + self.http_req(|| self.http.delete(make_url(&self.api_url))) + } +} + +pub trait RequestExt: Sized { + fn transfer_auth(self, client: &MuralPay) -> Result; + + async fn send_mural(self) -> crate::Result; +} + +const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key"; + +impl RequestExt for reqwest::RequestBuilder { + fn transfer_auth(self, client: &MuralPay) -> Result { + let transfer_api_key = client + .transfer_api_key + .as_ref() + .ok_or(TransferError::NoTransferKey)?; + + Ok(self + .header(HEADER_TRANSFER_API_KEY, transfer_api_key.expose_secret())) + } + + async fn send_mural(self) -> crate::Result { + let resp = self.send().await?; + let status = resp.status(); + if status.is_client_error() || status.is_server_error() { + let json = resp.bytes().await?; + let err = serde_json::from_slice::(&json) + .map_err(|source| MuralError::DecodeError { source, json })?; + Err(MuralError::Api(err)) + } else { + let json = resp.bytes().await?; + let t = serde_json::from_slice::(&json) + .map_err(|source| MuralError::Decode { source, json })?; + Ok(t) + } + } +} + +macro_rules! display_as_serialize { + ($T:ty) => { + const _: () = { + use std::fmt; + + impl fmt::Display for $T { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = + serde_json::to_value(self).map_err(|_| fmt::Error)?; + let value = value.as_str().ok_or(fmt::Error)?; + write!(f, "{value}") + } + } + }; + }; +} + +pub(crate) use display_as_serialize;