Mural Pay integration (#4520)

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* Temporarily disable Venmo and PayPal methods from frontend

* wip: counterparties

* Start on counterparties and payment methods API

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* Add fees to Mural

* Payout history route and bank details

* Re-add legacy PayPal/Venmo options for US

* move the mural bank details route

* Add utoipa support to payout endpoints

* address some PR comments

* add CORS to new utoipa routes

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* Add Mural balance to bank balance info

* Add more Tremendous currencies support

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

---------

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

View File

@@ -57,3 +57,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse
### Postgres ### Postgres
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance. 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.

171
Cargo.lock generated
View File

@@ -443,6 +443,12 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]] [[package]]
name = "arg_enum_proc_macro" name = "arg_enum_proc_macro"
version = "0.3.4" version = "0.3.4"
@@ -1872,7 +1878,7 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -2972,6 +2978,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 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]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -2979,7 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -2993,6 +3008,12 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -3954,6 +3975,22 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.17" version = "0.1.17"
@@ -4609,6 +4646,7 @@ dependencies = [
"actix-web", "actix-web",
"actix-web-prom", "actix-web-prom",
"actix-ws", "actix-ws",
"arc-swap",
"argon2", "argon2",
"ariadne", "ariadne",
"async-stripe", "async-stripe",
@@ -4644,6 +4682,7 @@ dependencies = [
"lettre", "lettre",
"meilisearch-sdk", "meilisearch-sdk",
"modrinth-maxmind", "modrinth-maxmind",
"muralpay",
"murmur2", "murmur2",
"paste", "paste",
"path-util", "path-util",
@@ -4667,6 +4706,7 @@ dependencies = [
"sha2", "sha2",
"spdx", "spdx",
"sqlx", "sqlx",
"strum",
"thiserror 2.0.17", "thiserror 2.0.17",
"tikv-jemalloc-ctl", "tikv-jemalloc-ctl",
"tikv-jemallocator", "tikv-jemallocator",
@@ -5241,6 +5281,31 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "murmur2" name = "murmur2"
version = "0.1.0" version = "0.1.0"
@@ -5277,6 +5342,23 @@ dependencies = [
"winapi", "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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -5884,12 +5966,50 @@ dependencies = [
"pathdiff", "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]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -7191,11 +7311,13 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper 1.7.0", "hyper 1.7.0",
"hyper-rustls 0.27.7", "hyper-rustls 0.27.7",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess", "mime_guess",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -7207,6 +7329,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tokio-util", "tokio-util",
"tower 0.5.2", "tower 0.5.2",
@@ -7430,6 +7553,7 @@ dependencies = [
"num-traits", "num-traits",
"rand 0.8.5", "rand 0.8.5",
"rkyv", "rkyv",
"rust_decimal_macros",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -7777,6 +7901,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@@ -8387,7 +8520,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics",
"foreign-types", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
"objc2 0.5.2", "objc2 0.5.2",
@@ -8757,6 +8890,27 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -9723,6 +9877,16 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.24.1" version = "0.24.1"
@@ -10381,6 +10545,7 @@ dependencies = [
"quote", "quote",
"regex", "regex",
"syn 2.0.106", "syn 2.0.106",
"uuid 1.18.1",
] ]
[[package]] [[package]]

View File

@@ -27,6 +27,7 @@ actix-rt = "2.11.0"
actix-web = "4.11.0" actix-web = "4.11.0"
actix-web-prom = "0.10.0" actix-web-prom = "0.10.0"
actix-ws = "0.3.0" actix-ws = "0.3.0"
arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" } ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false } 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 } meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-maxmind = { path = "packages/modrinth-maxmind" } modrinth-maxmind = { path = "packages/modrinth-maxmind" }
modrinth-util = { path = "packages/modrinth-util" } modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
murmur2 = "0.1.0" murmur2 = "0.1.0"
native-dialog = "0.9.2" native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false } 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" rustls = "0.23.32"
rusty-money = "0.4.1" rusty-money = "0.4.1"
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [ sentry = { version = "0.45.0", default-features = false, features = [
"backtrace", "backtrace",
"contexts", "contexts",
@@ -161,6 +164,7 @@ sha2 = "0.10.9"
shlex = "1.3.0" shlex = "1.3.0"
spdx = "0.12.0" spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false } sqlx = { version = "0.8.6", default-features = false }
strum = "0.27.2"
sysinfo = { version = "0.37.2", default-features = false } sysinfo = { version = "0.37.2", default-features = false }
tar = "0.4.44" tar = "0.4.44"
tauri = "2.8.5" tauri = "2.8.5"

View File

@@ -9,7 +9,7 @@ extend-exclude = [
# contains licenses like `CC-BY-ND-4.0` # contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts", "packages/moderation/src/data/stages/license.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged # 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] [default.extend-words]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1105,6 +1105,9 @@ COPY public.users (id, github_id, username, email, avatar_url, bio, created, rol
\. \.
INSERT INTO sessions (id, session, user_id, created, last_login, expires, refresh_expires, city, country, ip, os, platform, user_agent) 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; COMMIT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
use ariadne::ids::UserId;
use eyre::Result;
use muralpay::{MuralError, TokenFeeRequest};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::{
queue::payouts::{AccountBalance, PayoutsQueue},
routes::ApiError,
util::error::Context,
};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MuralPayoutRequest {
Fiat {
bank_name: String,
bank_account_owner: String,
fiat_and_rail_details: muralpay::FiatAndRailDetails,
},
Blockchain {
wallet_address: String,
},
}
impl PayoutsQueue {
pub async fn compute_muralpay_fees(
&self,
amount: Decimal,
fiat_and_rail_code: muralpay::FiatAndRailCode,
) -> Result<muralpay::TokenPayoutFee, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let fees = muralpay
.client
.get_fees_for_token_amount(&[TokenFeeRequest {
amount: muralpay::TokenAmount {
token_symbol: muralpay::USDC.into(),
token_amount: amount,
},
fiat_and_rail_code,
}])
.await
.wrap_internal_err("failed to request fees")?;
let fee = fees
.into_iter()
.next()
.wrap_internal_err("no fees returned")?;
Ok(fee)
}
pub async fn create_muralpay_payout_request(
&self,
user_id: UserId,
amount: muralpay::TokenAmount,
payout_details: MuralPayoutRequest,
recipient_info: muralpay::PayoutRecipientInfo,
) -> Result<muralpay::PayoutRequest, ApiError> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_internal_err("Mural Pay client not available")?;
let payout_details = match payout_details {
MuralPayoutRequest::Fiat {
bank_name,
bank_account_owner,
fiat_and_rail_details,
} => muralpay::CreatePayoutDetails::Fiat {
bank_name,
bank_account_owner,
developer_fee: None,
fiat_and_rail_details,
},
MuralPayoutRequest::Blockchain { wallet_address } => {
muralpay::CreatePayoutDetails::Blockchain {
wallet_details: muralpay::WalletDetails {
// only Polygon chain is currently supported
blockchain: muralpay::Blockchain::Polygon,
wallet_address,
},
}
}
};
let payout = muralpay::CreatePayout {
amount,
payout_details,
recipient_info,
supporting_details: None,
};
let payout_request = muralpay
.client
.create_payout_request(
muralpay.source_account_id,
Some(format!("User {user_id}")),
&[payout],
)
.await
.map_err(|err| match err {
MuralError::Api(err) => ApiError::Request(err.into()),
err => ApiError::Internal(err.into()),
})?;
// try to immediately execute the payout request...
// use a poor man's try/catch block using this `async move {}`
// to catch any errors within this block
let result = async move {
muralpay
.client
.execute_payout_request(payout_request.id)
.await
.wrap_internal_err("failed to execute payout request")?;
eyre::Ok(())
}
.await;
// and if it fails, make sure to immediately cancel it -
// we don't want floating payout requests
if let Err(err) = result {
muralpay
.client
.cancel_payout_request(payout_request.id)
.await
.wrap_internal_err(
"failed to cancel unexecuted payout request",
)?;
return Err(ApiError::Internal(err));
}
Ok(payout_request)
}
pub async fn cancel_muralpay_payout_request(
&self,
id: muralpay::PayoutRequestId,
) -> Result<()> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_err("Mural Pay client not available")?;
muralpay.client.cancel_payout_request(id).await?;
Ok(())
}
pub async fn get_mural_balance(&self) -> Result<Option<AccountBalance>> {
let muralpay = self.muralpay.load();
let muralpay = muralpay
.as_ref()
.wrap_err("Mural Pay client not available")?;
let account = muralpay
.client
.get_account(muralpay.source_account_id)
.await?;
let details = account
.account_details
.wrap_err("source account does not have details")?;
let available = details
.balances
.iter()
.map(|balance| {
if balance.token_symbol == muralpay::USDC {
balance.token_amount
} else {
Decimal::ZERO
}
})
.sum::<Decimal>();
Ok(Some(AccountBalance {
available,
pending: Decimal::ZERO,
}))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/)

View File

@@ -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<OutputFormat>,
#[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<String>,
},
/// 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 = <Args as clap::Parser>::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<TokenPayoutFee> {
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<FiatPayoutFee> {
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<T: Debug + Serialize>(output_format: Option<OutputFormat>, 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);
}
}
}

View File

@@ -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<Vec<Account>, MuralError> {
self.http_get(|base| format!("{base}/api/accounts"))
.send_mural()
.await
}
pub async fn get_account(
&self,
id: AccountId,
) -> Result<Account, MuralError> {
self.http_get(|base| format!("{base}/api/accounts/{id}"))
.send_mural()
.await
}
pub async fn create_account(
&self,
name: impl AsRef<str>,
description: Option<impl AsRef<str>>,
) -> Result<Account, MuralError> {
#[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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().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<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_api_enabled: bool,
pub status: AccountStatus,
pub account_details: Option<AccountDetails>,
}
#[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<TokenAmount>,
pub payin_methods: Vec<PayinMethod>,
}
#[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<DestinationToken>,
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<FiatAmount>,
}
#[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<FiatAmount>,
#[serde(with = "rust_decimal::serde::float_option", default)]
pub developer_fee_percentage: Option<Decimal>,
}
#[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<String>,
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<String>,
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 },
}

View File

@@ -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<SearchParams<CounterpartyId>>,
) -> Result<SearchResponse<CounterpartyId, Counterparty>, MuralError> {
self.http_post(|base| format!("{base}/api/counterparties/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.send_mural()
.await
}
pub async fn get_counterparty(
&self,
id: CounterpartyId,
) -> Result<Counterparty, MuralError> {
self.http_get(|base| {
format!("{base}/api/counterparties/counterparty/{id}")
})
.send_mural()
.await
}
pub async fn create_counterparty(
&self,
counterparty: &CreateCounterparty,
) -> Result<Counterparty, MuralError> {
#[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<Counterparty, MuralError> {
#[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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().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<Utc>,
pub updated_at: DateTime<Utc>,
pub alias: Option<String>,
#[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<String>,
first_name: String,
last_name: String,
email: String,
physical_address: PhysicalAddress,
},
#[serde(rename_all = "camelCase")]
Business {
alias: Option<String>,
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<String>,
first_name: Option<String>,
last_name: Option<String>,
email: Option<String>,
physical_address: Option<PhysicalAddress>,
},
#[serde(rename_all = "camelCase")]
Business {
alias: Option<String>,
name: Option<String>,
email: Option<String>,
physical_address: Option<PhysicalAddress>,
},
}

View File

@@ -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<T, E = MuralError> = std::result::Result<T, E>;
#[derive(Debug, Display, Error, From)]
pub enum TransferError {
#[display("no transfer API key")]
NoTransferKey,
#[display("API error")]
Api(Box<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,
},
}
impl From<MuralError> 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<String>,
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
}
fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
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"))
}
}

View File

@@ -1,22 +1,31 @@
#![doc = include_str!("../README.md")] #![doc = include_str!("../README.md")]
mod account; mod account;
mod counterparty;
mod error; mod error;
mod organization; mod organization;
mod payout; mod payout;
mod payout_method;
mod serde_iso3166;
mod util; mod util;
pub use {account::*, error::*, organization::*, payout::*}; pub use {
account::*, counterparty::*, error::*, organization::*, payout::*,
payout_method::*,
};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use secrecy::SecretString; use secrecy::SecretString;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ops::Deref; use std::{ops::Deref, str::FromStr};
use uuid::Uuid; use uuid::Uuid;
pub const API_URL: &str = "https://api.muralpay.com"; pub const API_URL: &str = "https://api.muralpay.com";
pub const SANDBOX_API_URL: &str = "https://api-staging.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)] #[derive(Debug)]
pub struct MuralPay { pub struct MuralPay {
pub http: reqwest::Client, pub http: reqwest::Client,
@@ -41,6 +50,7 @@ impl MuralPay {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Blockchain { pub enum Blockchain {
Ethereum, Ethereum,
@@ -49,7 +59,10 @@ pub enum Blockchain {
Celo, Celo,
} }
crate::util::display_as_serialize!(Blockchain);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "SCREAMING-KEBAB-CASE")] #[serde(rename_all = "SCREAMING-KEBAB-CASE")]
pub enum CurrencyCode { pub enum CurrencyCode {
Usd, Usd,
@@ -65,7 +78,20 @@ pub enum CurrencyCode {
Zar, 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")] #[serde(rename_all = "kebab-case")]
pub enum FiatAndRailCode { pub enum FiatAndRailCode {
Usd, Usd,
@@ -84,7 +110,18 @@ pub enum FiatAndRailCode {
UsdPanama, UsdPanama,
} }
crate::util::display_as_serialize!(FiatAndRailCode);
impl FromStr for FiatAndRailCode {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_value(serde_json::Value::String(s.to_owned()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WalletDetails { pub struct WalletDetails {
pub blockchain: Blockchain, pub blockchain: Blockchain,
@@ -92,15 +129,19 @@ pub struct WalletDetails {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TokenAmount { pub struct TokenAmount {
#[serde(with = "rust_decimal::serde::float")]
pub token_amount: Decimal, pub token_amount: Decimal,
pub token_symbol: String, pub token_symbol: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FiatAmount { pub struct FiatAmount {
#[serde(with = "rust_decimal::serde::float")]
pub fiat_amount: Decimal, pub fiat_amount: Decimal,
pub fiat_currency_code: CurrencyCode, pub fiat_currency_code: CurrencyCode,
} }
@@ -126,6 +167,7 @@ impl<Id: Deref<Target = Uuid> + Clone> SearchParams<Id> {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SearchResponse<Id, T> { pub struct SearchResponse<Id, T> {
pub total: u64, pub total: u64,

View File

@@ -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<SearchResponse<OrganizationId, Organization>, MuralError> {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
#[serde(skip_serializing_if = "Option::is_none")]
filter: Option<Filter>,
}
#[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::<Vec<_>>();
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<Organization, MuralError> {
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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().map(Self)
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct SearchRequest {
pub limit: Option<u64>,
pub next_id: Option<Uuid>,
pub name: Option<String>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub first_name: String,
pub last_name: String,
pub tos_status: TosStatus,
pub kyc_status: KycStatus,
pub currency_capabilities: Vec<CurrencyCapability>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub name: String,
pub tos_status: TosStatus,
pub kyc_status: KycStatus,
pub currency_capabilities: Vec<CurrencyCapability>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub first_name: String,
pub last_name: String,
pub approver: Approver,
pub tos_status: TosStatus,
pub kyc_status: KycStatus,
pub currency_capabilities: Vec<CurrencyCapability>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub name: String,
pub approver: Approver,
pub tos_status: TosStatus,
pub kyc_status: KycStatus,
pub currency_capabilities: Vec<CurrencyCapability>,
}
#[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<Utc>,
pub name: String,
pub email: String,
pub auth_methods: Vec<String>,
}
#[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<Utc>,
},
Errored {
details: String,
errored_at: DateTime<Utc>,
},
Rejected {
reason: String,
rejected_at: DateTime<Utc>,
},
PreValidationFailed {
failed_validation_reason: FailedValidationReason,
failed_validation_at: DateTime<Utc>,
},
NeedsUpdate {
needs_update_reason: String,
verification_status_updated_at: DateTime<Utc>,
},
}
#[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,
}

View File

@@ -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<PayoutStatusFilter>,
params: Option<SearchParams<PayoutRequestId>>,
) -> Result<SearchResponse<PayoutRequestId, PayoutRequest>, MuralError>
{
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Body {
filter: Option<PayoutStatusFilter>,
}
let body = Body { filter };
self.http_post(|base| format!("{base}/api/payouts/search"))
.query(&params.map(|p| p.to_query()).unwrap_or_default())
.json(&body)
.send_mural()
.await
}
pub async fn get_payout_request(
&self,
id: PayoutRequestId,
) -> Result<PayoutRequest, MuralError> {
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<Vec<TokenPayoutFee>, 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<Vec<FiatPayoutFee>, 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<impl AsRef<str>>,
payouts: &[CreatePayout],
) -> Result<PayoutRequest, MuralError> {
#[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<PayoutRequest, TransferError> {
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<PayoutRequest, TransferError> {
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<BankDetailsResponse, MuralError> {
let query = fiat_currency_and_rail
.iter()
.map(|code| ("fiatCurrencyAndRail", code.to_string()))
.collect::<Vec<_>>();
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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().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<String> },
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
pub source_account_id: AccountId,
pub transaction_hash: Option<String>,
pub memo: Option<String>,
pub status: PayoutStatus,
pub payouts: Vec<Payout>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
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<DeveloperFee>,
}
#[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<Utc>,
},
#[serde(rename_all = "camelCase")]
OnHold {
initiated_at: DateTime<Utc>,
},
#[serde(rename_all = "camelCase")]
Completed {
initiated_at: DateTime<Utc>,
completed_at: DateTime<Utc>,
},
#[serde(rename_all = "camelCase")]
Failed {
initiated_at: DateTime<Utc>,
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<Decimal>,
}
#[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<SupportingDetails>,
}
#[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<DeveloperFee>,
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<Self, InvalidDob> {
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<Self, Self::Err> {
let [year, month, day] = s
.split('-')
.collect::<Vec<_>>()
.try_into()
.map_err(|_| InvalidDob::NotThreeSegments)?;
let year = year.parse::<u16>().map_err(|_| InvalidDob::YearNotInt)?;
let month = month.parse::<u8>().map_err(|_| InvalidDob::MonthNotInt)?;
let day = day.parse::<u8>().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<String>,
#[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<String>, // data:image/jpeg;base64,...
pub payout_purpose: Option<PayoutPurpose>,
}
#[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<String>,
}

View File

@@ -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<SearchParams<PayoutMethodId>>,
) -> Result<SearchResponse<PayoutMethodId, PayoutMethod>, MuralError> {
self.http_post(|base| {
format!(
"{base}/api/counterparties/{counterparty_id}/payout-methods/search"
)
})
.query(&params.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<PayoutMethod, MuralError> {
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<str>,
payout_method: &PayoutMethodDetails,
) -> Result<PayoutMethod, MuralError> {
#[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 = <Uuid as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().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<Self, Self::Err> {
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<Utc>,
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,
}

View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, de::Error};
use std::borrow::Cow;
use rust_iso3166::CountryCode;
pub fn serialize<S: serde::Serializer>(
v: &CountryCode,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(v.alpha2)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<CountryCode, D::Error> {
<Cow<'_, str>>::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")
})
})
}

View File

@@ -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<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.get(make_url(&self.api_url)))
}
pub(crate) fn http_post<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.post(make_url(&self.api_url)))
}
pub(crate) fn http_put<U: IntoUrl>(
&self,
make_url: impl FnOnce(&str) -> U,
) -> RequestBuilder {
self.http_req(|| self.http.put(make_url(&self.api_url)))
}
pub(crate) fn http_delete<U: IntoUrl>(
&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<Self, TransferError>;
async fn send_mural<T: DeserializeOwned>(self) -> crate::Result<T>;
}
const HEADER_TRANSFER_API_KEY: &str = "transfer-api-key";
impl RequestExt for reqwest::RequestBuilder {
fn transfer_auth(self, client: &MuralPay) -> Result<Self, TransferError> {
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<T: DeserializeOwned>(self) -> crate::Result<T> {
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::<ApiError>(&json)
.map_err(|source| MuralError::DecodeError { source, json })?;
Err(MuralError::Api(err))
} else {
let json = resp.bytes().await?;
let t = serde_json::from_slice::<T>(&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;