You've already forked AstralRinth
forked from didirus/AstralRinth
Mural Pay integration (#4520)
* wip: muralpay integration * Basic Mural Pay API bindings * Fix clippy * use dotenvy in muralpay example * Refactor payout creation code * wip: muralpay payout requests * Mural Pay payouts work * Fix clippy * add mural pay fees API * Work on payout fee API * Fees API for more payment methods * Fix CI * Temporarily disable Venmo and PayPal methods from frontend * wip: counterparties * Start on counterparties and payment methods API * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * Add countries to muralpay fiat methods * Compile fix * Add exchange rate info to fees endpoint * Add fees to premium Tremendous options * Add delivery email field to Tremendous payouts * Add Tremendous product category to payout methods * Add bank details API to muralpay * Fix CI * Fix CI * Remove prepaid visa, compute fees properly for Tremendous methods * Add more details to Tremendous errors * Add fees to Mural * Payout history route and bank details * Re-add legacy PayPal/Venmo options for US * move the mural bank details route * Add utoipa support to payout endpoints * address some PR comments * add CORS to new utoipa routes * Immediately approve mural payouts * Add currency support to Tremendous payouts * Currency forex * add forex to tremendous fee request * Add Mural balance to bank balance info * Add more Tremendous currencies support * Transaction payouts available use the correct date * Address my own review comment * Address PR comments * Change Mural withdrawal limit to 3k * maybe fix tremendous gift cards * Change how Mural minimum withdrawals are calculated * Tweak min/max withdrawal values --------- Co-authored-by: Calum H. <contact@cal.engineer> Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
@@ -57,3 +57,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse
|
||||
### Postgres
|
||||
|
||||
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance.
|
||||
|
||||
# Guidelines
|
||||
|
||||
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to.
|
||||
|
||||
171
Cargo.lock
generated
171
Cargo.lock
generated
@@ -443,6 +443,12 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
@@ -1872,7 +1878,7 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2972,6 +2978,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2979,7 +2994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2993,6 +3008,12 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -3954,6 +3975,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.17"
|
||||
@@ -4609,6 +4646,7 @@ dependencies = [
|
||||
"actix-web",
|
||||
"actix-web-prom",
|
||||
"actix-ws",
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"ariadne",
|
||||
"async-stripe",
|
||||
@@ -4644,6 +4682,7 @@ dependencies = [
|
||||
"lettre",
|
||||
"meilisearch-sdk",
|
||||
"modrinth-maxmind",
|
||||
"muralpay",
|
||||
"murmur2",
|
||||
"paste",
|
||||
"path-util",
|
||||
@@ -4667,6 +4706,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"spdx",
|
||||
"sqlx",
|
||||
"strum",
|
||||
"thiserror 2.0.17",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tikv-jemallocator",
|
||||
@@ -5241,6 +5281,31 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muralpay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"color-eyre",
|
||||
"derive_more 2.0.1",
|
||||
"dotenvy",
|
||||
"eyre",
|
||||
"reqwest",
|
||||
"rust_decimal",
|
||||
"rust_iso3166",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"strum",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"uuid 1.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "murmur2"
|
||||
version = "0.1.0"
|
||||
@@ -5277,6 +5342,23 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -5884,12 +5966,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -7191,11 +7311,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper 1.7.0",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -7207,6 +7329,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
@@ -7430,6 +7553,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rkyv",
|
||||
"rust_decimal_macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7777,6 +7901,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -8387,7 +8520,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -8757,6 +8890,27 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -9723,6 +9877,16 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
@@ -10381,6 +10545,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 2.0.106",
|
||||
"uuid 1.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -27,6 +27,7 @@ actix-rt = "2.11.0"
|
||||
actix-web = "4.11.0"
|
||||
actix-web-prom = "0.10.0"
|
||||
actix-ws = "0.3.0"
|
||||
arc-swap = "1.7.1"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async-compression = { version = "0.4.32", default-features = false }
|
||||
@@ -109,6 +110,7 @@ maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
|
||||
modrinth-util = { path = "packages/modrinth-util" }
|
||||
muralpay = { path = "packages/muralpay" }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.2"
|
||||
notify = { version = "8.2.0", default-features = false }
|
||||
@@ -139,6 +141,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
] }
|
||||
rustls = "0.23.32"
|
||||
rusty-money = "0.4.1"
|
||||
secrecy = "0.10.3"
|
||||
sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
@@ -161,6 +164,7 @@ sha2 = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
strum = "0.27.2"
|
||||
sysinfo = { version = "0.37.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.8.5"
|
||||
|
||||
@@ -9,7 +9,7 @@ extend-exclude = [
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts.rs",
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -146,3 +146,8 @@ GOTENBERG_URL=http://labrinth-gotenberg:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
MURALPAY_API_URL=https://api.muralpay.com
|
||||
MURALPAY_API_KEY=none
|
||||
MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=none
|
||||
|
||||
@@ -147,3 +147,8 @@ GOTENBERG_URL=http://localhost:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
MURALPAY_API_URL=https://api-staging.muralpay.com
|
||||
MURALPAY_API_KEY=none
|
||||
MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=none
|
||||
|
||||
28
apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json
generated
Normal file
28
apps/labrinth/.sqlx/query-0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT date_available, amount\n FROM payouts_values\n WHERE user_id = $1\n AND NOW() >= date_available",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "date_available",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "amount",
|
||||
"type_info": "Numeric"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0a01edcb023f6fd1bdfb3a6b77ad4fd183fd439ddcbbac76471a9771d4f29b61"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ actix-rt = { workspace = true }
|
||||
actix-web = { workspace = true }
|
||||
actix-web-prom = { workspace = true, features = ["process"] }
|
||||
actix-ws = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
ariadne = { workspace = true }
|
||||
async-stripe = { workspace = true, features = [
|
||||
@@ -70,6 +71,7 @@ json-patch = { workspace = true }
|
||||
lettre = { workspace = true }
|
||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||
modrinth-maxmind = { workspace = true }
|
||||
muralpay = { workspace = true, features = ["utoipa"] }
|
||||
murmur2 = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
@@ -110,6 +112,7 @@ sqlx = { workspace = true, features = [
|
||||
"rust_decimal",
|
||||
"tls-rustls-aws-lc-rs",
|
||||
] }
|
||||
strum = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
|
||||
tokio-stream = { workspace = true }
|
||||
|
||||
@@ -1105,6 +1105,9 @@ COPY public.users (id, github_id, username, email, avatar_url, bio, created, rol
|
||||
\.
|
||||
|
||||
INSERT INTO sessions (id, session, user_id, created, last_login, expires, refresh_expires, city, country, ip, os, platform, user_agent)
|
||||
VALUES (93083445641246, 'mra_admin', 103587649610509, '2025-10-20 14:58:53.128901+00', '2025-10-20 14:58:53.128901+00', '2025-11-03 14:58:53.128901+00', '2025-12-19 14:58:53.128901+00', '', '', '127.0.0.1', 'Linux', 'Chrome', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36');
|
||||
VALUES (93083445641246, 'mra_admin', 103587649610509, '2025-10-20 14:58:53.128901+00', '2025-10-20 14:58:53.128901+00', '2030-11-03 14:58:53.128901+00', '2030-12-19 14:58:53.128901+00', '', '', '127.0.0.1', 'Linux', 'Chrome', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36');
|
||||
|
||||
INSERT INTO payouts_values (user_id, amount, created, date_available)
|
||||
VALUES (103587649610509, 1000.00000000000000000000, '2025-10-23 00:00:00+00', '2025-10-23 00:00:00+00');
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -38,7 +38,7 @@ impl DBPayout {
|
||||
self.fee,
|
||||
self.user_id.0,
|
||||
self.status.as_str(),
|
||||
self.method.map(|x| x.as_str()),
|
||||
self.method.as_ref().map(|x| x.as_str()),
|
||||
self.method_address,
|
||||
self.platform_id,
|
||||
)
|
||||
@@ -84,7 +84,7 @@ impl DBPayout {
|
||||
created: r.created,
|
||||
status: PayoutStatus::from_string(&r.status),
|
||||
amount: r.amount,
|
||||
method: r.method.map(|x| PayoutMethodType::from_string(&x)),
|
||||
method: r.method.and_then(|x| PayoutMethodType::from_string(&x)),
|
||||
method_address: r.method_address,
|
||||
platform_id: r.platform_id,
|
||||
fee: r.fee,
|
||||
|
||||
@@ -5,7 +5,16 @@ use sqlx::{query, query_scalar};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
pub enum FormType {
|
||||
#[serde(rename = "W-8BEN")]
|
||||
|
||||
@@ -527,5 +527,10 @@ pub fn check_env_vars() -> bool {
|
||||
|
||||
failed |= check_var::<String>("ARCHON_URL");
|
||||
|
||||
failed |= check_var::<String>("MURALPAY_API_URL");
|
||||
failed |= check_var::<String>("MURALPAY_API_KEY");
|
||||
failed |= check_var::<String>("MURALPAY_TRANSFER_API_KEY");
|
||||
failed |= check_var::<String>("MURALPAY_SOURCE_ACCOUNT_ID");
|
||||
|
||||
failed
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::models::ids::PayoutId;
|
||||
use std::{cmp, collections::HashMap, fmt};
|
||||
|
||||
use crate::{models::ids::PayoutId, queue::payouts::mural::MuralPayoutRequest};
|
||||
use ariadne::ids::UserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -37,13 +39,47 @@ impl Payout {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "method", rename_all = "lowercase")]
|
||||
#[expect(
|
||||
clippy::large_enum_variant,
|
||||
reason = "acceptable since values of this type are not moved much"
|
||||
)]
|
||||
pub enum PayoutMethodRequest {
|
||||
Venmo,
|
||||
PayPal,
|
||||
Tremendous { method_details: TremendousDetails },
|
||||
MuralPay { method_details: MuralPayDetails },
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PayoutMethodType {
|
||||
Venmo,
|
||||
PayPal,
|
||||
Tremendous,
|
||||
Unknown,
|
||||
MuralPay,
|
||||
}
|
||||
|
||||
impl PayoutMethodRequest {
|
||||
pub fn method_type(&self) -> PayoutMethodType {
|
||||
match self {
|
||||
Self::Venmo => PayoutMethodType::Venmo,
|
||||
Self::PayPal => PayoutMethodType::PayPal,
|
||||
Self::Tremendous { .. } => PayoutMethodType::Tremendous,
|
||||
Self::MuralPay { .. } => PayoutMethodType::MuralPay,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PayoutMethodType {
|
||||
@@ -52,27 +88,85 @@ impl std::fmt::Display for PayoutMethodType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct TremendousDetails {
|
||||
pub delivery_email: String,
|
||||
#[schema(inline)]
|
||||
pub currency: Option<TremendousCurrency>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum TremendousCurrency {
|
||||
Usd,
|
||||
Gbp,
|
||||
Cad,
|
||||
Eur,
|
||||
Aud,
|
||||
Chf,
|
||||
Czk,
|
||||
Dkk,
|
||||
Mxn,
|
||||
Nok,
|
||||
Nzd,
|
||||
Pln,
|
||||
Sek,
|
||||
Sgd,
|
||||
}
|
||||
|
||||
impl fmt::Display for TremendousCurrency {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = serde_json::to_value(self).map_err(|_| fmt::Error)?;
|
||||
let s = s.as_str().ok_or(fmt::Error)?;
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TremendousForexResponse {
|
||||
pub forex: HashMap<String, Decimal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct MuralPayDetails {
|
||||
pub payout_details: MuralPayoutRequest,
|
||||
pub recipient_info: muralpay::PayoutRecipientInfo,
|
||||
}
|
||||
|
||||
impl PayoutMethodType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PayoutMethodType::Venmo => "venmo",
|
||||
PayoutMethodType::PayPal => "paypal",
|
||||
PayoutMethodType::Tremendous => "tremendous",
|
||||
PayoutMethodType::Unknown => "unknown",
|
||||
PayoutMethodType::MuralPay => "muralpay",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(string: &str) -> PayoutMethodType {
|
||||
pub fn from_string(string: &str) -> Option<PayoutMethodType> {
|
||||
match string {
|
||||
"venmo" => PayoutMethodType::Venmo,
|
||||
"paypal" => PayoutMethodType::PayPal,
|
||||
"tremendous" => PayoutMethodType::Tremendous,
|
||||
_ => PayoutMethodType::Unknown,
|
||||
"venmo" => Some(PayoutMethodType::Venmo),
|
||||
"paypal" => Some(PayoutMethodType::PayPal),
|
||||
"tremendous" => Some(PayoutMethodType::Tremendous),
|
||||
"muralpay" => Some(PayoutMethodType::MuralPay),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PayoutStatus {
|
||||
Success,
|
||||
@@ -119,6 +213,8 @@ pub struct PayoutMethod {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: PayoutMethodType,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub supported_countries: Vec<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_logo_url: Option<String>,
|
||||
@@ -136,6 +232,15 @@ pub struct PayoutMethodFee {
|
||||
pub max: Option<Decimal>,
|
||||
}
|
||||
|
||||
impl PayoutMethodFee {
|
||||
pub fn compute_fee(&self, value: Decimal) -> Decimal {
|
||||
cmp::min(
|
||||
cmp::max(self.min, self.percentage * value),
|
||||
self.max.unwrap_or(Decimal::MAX),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PayoutDecimal(pub Decimal);
|
||||
|
||||
|
||||
@@ -2,21 +2,28 @@ use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::payouts_values_notifications;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::payouts::{
|
||||
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee,
|
||||
PayoutMethodType,
|
||||
MuralPayDetails, PayoutDecimal, PayoutInterval, PayoutMethod,
|
||||
PayoutMethodFee, PayoutMethodRequest, PayoutMethodType,
|
||||
TremendousForexResponse,
|
||||
};
|
||||
use crate::models::projects::MonetizationStatus;
|
||||
use crate::queue::payouts::mural::MuralPayoutRequest;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::env::env_var;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::webhook::{
|
||||
PayoutSourceAlertType, send_slack_payout_source_alert_webhook,
|
||||
};
|
||||
use arc_swap::ArcSwapOption;
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use eyre::{Result, eyre};
|
||||
use futures::TryStreamExt;
|
||||
use muralpay::MuralPay;
|
||||
use reqwest::Method;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use rust_decimal::{Decimal, dec};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -24,11 +31,19 @@ use sqlx::PgPool;
|
||||
use sqlx::postgres::PgQueryResult;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub mod mural;
|
||||
|
||||
pub struct PayoutsQueue {
|
||||
credential: RwLock<Option<PayPalCredentials>>,
|
||||
payout_options: RwLock<Option<PayoutMethods>>,
|
||||
pub muralpay: ArcSwapOption<MuralPayConfig>,
|
||||
}
|
||||
|
||||
pub struct MuralPayConfig {
|
||||
pub client: MuralPay,
|
||||
pub source_account_id: muralpay::AccountId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -55,12 +70,102 @@ impl Default for PayoutsQueue {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_muralpay() -> Result<MuralPayConfig> {
|
||||
let api_url = env_var("MURALPAY_API_URL")?;
|
||||
let api_key = env_var("MURALPAY_API_KEY")?;
|
||||
let transfer_api_key = env_var("MURALPAY_TRANSFER_API_KEY")?;
|
||||
let source_account_id = env_var("MURALPAY_SOURCE_ACCOUNT_ID")?
|
||||
.parse::<muralpay::AccountId>()
|
||||
.wrap_err("failed to parse source account ID")?;
|
||||
|
||||
let client = MuralPay::new(api_url, api_key, Some(transfer_api_key));
|
||||
|
||||
Ok(MuralPayConfig {
|
||||
client,
|
||||
source_account_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_muralpay_methods() -> Vec<PayoutMethod> {
|
||||
let all_countries = rust_iso3166::ALL
|
||||
.iter()
|
||||
.map(|x| x.alpha2)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let currencies = vec![
|
||||
("blockchain_usdc_polygon", "USDC on Polygon", all_countries),
|
||||
("fiat_mxn", "MXN", vec!["MX"]),
|
||||
("fiat_brl", "BRL", vec!["BR"]),
|
||||
("fiat_clp", "CLP", vec!["CL"]),
|
||||
("fiat_crc", "CRC", vec!["CR"]),
|
||||
("fiat_pen", "PEN", vec!["PE"]),
|
||||
// ("fiat_dop", "DOP"), // unsupported in API
|
||||
// ("fiat_uyu", "UYU"), // unsupported in API
|
||||
("fiat_ars", "ARS", vec!["AR"]),
|
||||
("fiat_cop", "COP", vec!["CO"]),
|
||||
("fiat_usd", "USD", vec!["US"]),
|
||||
("fiat_usd-peru", "USD Peru", vec!["PE"]),
|
||||
// ("fiat_usd-panama", "USD Panama"), // by request
|
||||
(
|
||||
"fiat_eur",
|
||||
"EUR",
|
||||
vec![
|
||||
"DE", "FR", "IT", "ES", "NL", "BE", "AT", "PT", "FI", "IE",
|
||||
"GR", "LU", "CY", "MT", "SK", "SI", "EE", "LV", "LT",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
currencies
|
||||
.into_iter()
|
||||
.map(|(id, currency, countries)| PayoutMethod {
|
||||
id: id.to_string(),
|
||||
type_: PayoutMethodType::MuralPay,
|
||||
name: format!("Mural Pay - {currency}"),
|
||||
category: None,
|
||||
supported_countries: countries
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
image_url: None,
|
||||
image_logo_url: None,
|
||||
interval: PayoutInterval::Standard {
|
||||
// Different countries and currencies supported by Mural have different fees.
|
||||
min: match id {
|
||||
// Due to relatively low volume of Peru withdrawals, fees are higher,
|
||||
// so we need to raise the minimum to cover these fees.
|
||||
"fiat_usd-peru" => Decimal::from(10),
|
||||
// USDC has much lower fees.
|
||||
"blockchain_usdc_polygon" => {
|
||||
Decimal::from(10) / Decimal::from(100)
|
||||
}
|
||||
_ => Decimal::from(5),
|
||||
},
|
||||
max: Decimal::from(10_000),
|
||||
},
|
||||
fee: PayoutMethodFee {
|
||||
percentage: Decimal::from(1) / Decimal::from(100),
|
||||
min: Decimal::ZERO,
|
||||
max: Some(Decimal::ZERO),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Batches payouts and handles token refresh
|
||||
impl PayoutsQueue {
|
||||
pub fn new() -> Self {
|
||||
let muralpay = create_muralpay()
|
||||
.inspect_err(|err| {
|
||||
warn!("Failed to create Mural Pay client: {err:#?}")
|
||||
})
|
||||
.ok();
|
||||
|
||||
PayoutsQueue {
|
||||
credential: RwLock::new(None),
|
||||
payout_options: RwLock::new(None),
|
||||
muralpay: ArcSwapOption::from_pointee(muralpay),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +377,7 @@ impl PayoutsQueue {
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousError {
|
||||
message: String,
|
||||
payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
let err =
|
||||
@@ -283,7 +389,10 @@ impl PayoutsQueue {
|
||||
)
|
||||
})?;
|
||||
|
||||
return Err(ApiError::Payments(err.message));
|
||||
return Err(ApiError::Payments(format!(
|
||||
"Tremendous error: {} ({:?})",
|
||||
err.message, err.payload
|
||||
)));
|
||||
}
|
||||
|
||||
return Err(ApiError::Payments(
|
||||
@@ -304,198 +413,23 @@ impl PayoutsQueue {
|
||||
|
||||
let mut methods = Vec::new();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Sku {
|
||||
pub min: Decimal,
|
||||
pub max: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProductImageType {
|
||||
Card,
|
||||
Logo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProductImage {
|
||||
pub src: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: ProductImageType,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProductCountry {
|
||||
pub abbr: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Product {
|
||||
pub id: String,
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
// pub description: String,
|
||||
// pub disclosure: String,
|
||||
pub skus: Vec<Sku>,
|
||||
pub currency_codes: Vec<String>,
|
||||
pub countries: Vec<ProductCountry>,
|
||||
pub images: Vec<ProductImage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TremendousResponse {
|
||||
pub products: Vec<Product>,
|
||||
}
|
||||
|
||||
let response = queue
|
||||
.make_tremendous_request::<(), TremendousResponse>(
|
||||
Method::GET,
|
||||
"products",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for product in response.products {
|
||||
const BLACKLISTED_IDS: &[&str] = &[
|
||||
// physical visa
|
||||
"A2J05SWPI2QG",
|
||||
// crypto
|
||||
"1UOOSHUUYTAM",
|
||||
"5EVJN47HPDFT",
|
||||
"NI9M4EVAVGFJ",
|
||||
"VLY29QHTMNGT",
|
||||
"7XU98H109Y3A",
|
||||
"0CGEDFP2UIKV",
|
||||
"PDYLQU0K073Y",
|
||||
"HCS5Z7O2NV5G",
|
||||
"IY1VMST1MOXS",
|
||||
"VRPZLJ7HCA8X",
|
||||
// bitcard (crypto)
|
||||
"GWQQS5RM8IZS",
|
||||
"896MYD4SGOGZ",
|
||||
"PWLEN1VZGMZA",
|
||||
"A2VRM96J5K5W",
|
||||
"HV9ICIM3JT7P",
|
||||
"K2KLSPVWC2Q4",
|
||||
"HRBRQLLTDF95",
|
||||
"UUBYLZVK7QAB",
|
||||
"BH8W3XEDEOJN",
|
||||
"7WGE043X1RYQ",
|
||||
"2B13MHUZZVTF",
|
||||
"JN6R44P86EYX",
|
||||
"DA8H43GU84SO",
|
||||
"QK2XAQHSDEH4",
|
||||
"J7K1IQFS76DK",
|
||||
"NL4JQ2G7UPRZ",
|
||||
"OEFTMSBA5ELH",
|
||||
"A3CQK6UHNV27",
|
||||
];
|
||||
const SUPPORTED_METHODS: &[&str] = &[
|
||||
"merchant_cards",
|
||||
"merchant_card",
|
||||
"visa",
|
||||
"bank",
|
||||
"ach",
|
||||
"visa_card",
|
||||
"charity",
|
||||
];
|
||||
|
||||
if !SUPPORTED_METHODS.contains(&&*product.category)
|
||||
|| BLACKLISTED_IDS.contains(&&*product.id)
|
||||
{
|
||||
continue;
|
||||
};
|
||||
|
||||
let method = PayoutMethod {
|
||||
id: product.id,
|
||||
type_: PayoutMethodType::Tremendous,
|
||||
name: product.name.clone(),
|
||||
supported_countries: product
|
||||
.countries
|
||||
.into_iter()
|
||||
.map(|x| x.abbr)
|
||||
.collect(),
|
||||
image_logo_url: product
|
||||
.images
|
||||
.iter()
|
||||
.find(|x| x.type_ == ProductImageType::Logo)
|
||||
.map(|x| x.src.clone()),
|
||||
image_url: product
|
||||
.images
|
||||
.into_iter()
|
||||
.find(|x| x.type_ == ProductImageType::Card)
|
||||
.map(|x| x.src),
|
||||
interval: if product.skus.len() > 1 {
|
||||
let mut values = product
|
||||
.skus
|
||||
.into_iter()
|
||||
.map(|x| PayoutDecimal(x.min))
|
||||
.collect::<Vec<_>>();
|
||||
values.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
PayoutInterval::Fixed { values }
|
||||
} else if let Some(first) = product.skus.first() {
|
||||
PayoutInterval::Standard {
|
||||
min: first.min,
|
||||
max: first.max,
|
||||
}
|
||||
} else {
|
||||
PayoutInterval::Standard {
|
||||
min: Decimal::ZERO,
|
||||
max: Decimal::from(5_000),
|
||||
}
|
||||
},
|
||||
fee: if product.category == "ach" {
|
||||
PayoutMethodFee {
|
||||
percentage: Decimal::from(4) / Decimal::from(100),
|
||||
min: Decimal::from(1) / Decimal::from(4),
|
||||
max: None,
|
||||
}
|
||||
} else {
|
||||
PayoutMethodFee {
|
||||
percentage: Decimal::default(),
|
||||
min: Decimal::default(),
|
||||
max: None,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
|
||||
if let PayoutInterval::Fixed { .. } = method.interval
|
||||
&& !product.currency_codes.contains(&"USD".to_string())
|
||||
{
|
||||
continue;
|
||||
match get_tremendous_payout_methods(queue).await {
|
||||
Ok(mut tremendous_methods) => {
|
||||
methods.append(&mut tremendous_methods);
|
||||
}
|
||||
|
||||
methods.push(method);
|
||||
}
|
||||
|
||||
const UPRANK_IDS: &[&str] =
|
||||
&["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"];
|
||||
const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"];
|
||||
|
||||
methods.sort_by(|a, b| {
|
||||
let a_top = UPRANK_IDS.contains(&&*a.id);
|
||||
let a_bottom = DOWNRANK_IDS.contains(&&*a.id);
|
||||
let b_top = UPRANK_IDS.contains(&&*b.id);
|
||||
let b_bottom = DOWNRANK_IDS.contains(&&*b.id);
|
||||
|
||||
match (a_top, a_bottom, b_top, b_bottom) {
|
||||
(true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically
|
||||
(_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically
|
||||
(true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first
|
||||
(_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first
|
||||
(_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first
|
||||
(_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first
|
||||
(_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to fetch Tremendous payout methods: {err:#?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let paypal_us = PayoutMethod {
|
||||
id: "paypal_us".to_string(),
|
||||
type_: PayoutMethodType::PayPal,
|
||||
name: "PayPal".to_string(),
|
||||
category: None,
|
||||
supported_countries: vec!["US".to_string()],
|
||||
image_url: None,
|
||||
image_logo_url: None,
|
||||
@@ -519,30 +453,7 @@ impl PayoutsQueue {
|
||||
methods.insert(1, venmo)
|
||||
}
|
||||
|
||||
methods.insert(
|
||||
2,
|
||||
PayoutMethod {
|
||||
id: "paypal_in".to_string(),
|
||||
type_: PayoutMethodType::PayPal,
|
||||
name: "PayPal".to_string(),
|
||||
supported_countries: rust_iso3166::ALL
|
||||
.iter()
|
||||
.filter(|x| x.alpha2 != "US")
|
||||
.map(|x| x.alpha2.to_string())
|
||||
.collect(),
|
||||
image_url: None,
|
||||
image_logo_url: None,
|
||||
interval: PayoutInterval::Standard {
|
||||
min: Decimal::from(1) / Decimal::from(4),
|
||||
max: Decimal::from(100_000),
|
||||
},
|
||||
fee: PayoutMethodFee {
|
||||
percentage: Decimal::from(2) / Decimal::from(100),
|
||||
min: Decimal::ZERO,
|
||||
max: Some(Decimal::from(20)),
|
||||
},
|
||||
},
|
||||
);
|
||||
methods.extend(create_muralpay_methods());
|
||||
|
||||
let new_options = PayoutMethods {
|
||||
options: methods,
|
||||
@@ -699,6 +610,333 @@ impl PayoutsQueue {
|
||||
/ Decimal::from(100),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn calculate_fees(
|
||||
&self,
|
||||
request: &PayoutMethodRequest,
|
||||
method_id: &str,
|
||||
amount: Decimal,
|
||||
) -> Result<PayoutFees, ApiError> {
|
||||
const MURAL_FEE: Decimal = dec!(0.01);
|
||||
|
||||
let get_method = async {
|
||||
let method = self
|
||||
.get_payout_methods()
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch payout methods")?
|
||||
.into_iter()
|
||||
.find(|method| method.id == method_id)
|
||||
.wrap_request_err("invalid payout method ID")?;
|
||||
Ok::<_, ApiError>(method)
|
||||
};
|
||||
|
||||
let fees = match request {
|
||||
PayoutMethodRequest::MuralPay {
|
||||
method_details:
|
||||
MuralPayDetails {
|
||||
payout_details: MuralPayoutRequest::Blockchain { .. },
|
||||
..
|
||||
},
|
||||
} => PayoutFees {
|
||||
method_fee: dec!(0),
|
||||
platform_fee: amount * MURAL_FEE,
|
||||
exchange_rate: None,
|
||||
},
|
||||
PayoutMethodRequest::MuralPay {
|
||||
method_details:
|
||||
MuralPayDetails {
|
||||
payout_details:
|
||||
MuralPayoutRequest::Fiat {
|
||||
fiat_and_rail_details,
|
||||
..
|
||||
},
|
||||
..
|
||||
},
|
||||
} => {
|
||||
let fiat_and_rail_code = fiat_and_rail_details.code();
|
||||
let fee = self
|
||||
.compute_muralpay_fees(amount, fiat_and_rail_code)
|
||||
.await?;
|
||||
|
||||
match fee {
|
||||
muralpay::TokenPayoutFee::Success {
|
||||
exchange_rate,
|
||||
fee_total,
|
||||
..
|
||||
} => PayoutFees {
|
||||
method_fee: fee_total.token_amount,
|
||||
platform_fee: amount * MURAL_FEE,
|
||||
exchange_rate: Some(exchange_rate),
|
||||
},
|
||||
muralpay::TokenPayoutFee::Error { message, .. } => {
|
||||
return Err(ApiError::Internal(eyre!(
|
||||
"failed to compute fee: {message}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
|
||||
let method = get_method.await?;
|
||||
let fee = method.fee.compute_fee(amount);
|
||||
PayoutFees {
|
||||
method_fee: fee,
|
||||
platform_fee: dec!(0),
|
||||
exchange_rate: None,
|
||||
}
|
||||
}
|
||||
PayoutMethodRequest::Tremendous { method_details } => {
|
||||
let method = get_method.await?;
|
||||
let fee = method.fee.compute_fee(amount);
|
||||
|
||||
let forex: TremendousForexResponse = self
|
||||
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch Tremendous forex")?;
|
||||
|
||||
let exchange_rate = if let Some(currency) =
|
||||
&method_details.currency
|
||||
{
|
||||
let currency_code = currency.to_string();
|
||||
let exchange_rate =
|
||||
forex.forex.get(¤cy_code).wrap_request_err_with(
|
||||
|| eyre!("no Tremendous forex data for {currency}"),
|
||||
)?;
|
||||
Some(*exchange_rate)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
PayoutFees {
|
||||
method_fee: fee,
|
||||
platform_fee: dec!(0),
|
||||
exchange_rate,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(fees)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PayoutFees {
|
||||
/// Fee which is taken by the underlying method we're using.
|
||||
///
|
||||
/// For example, if a user withdraws $10.00 and the method takes a
|
||||
/// 10% cut, then we submit a payout request of $10.00 to the method,
|
||||
/// and only $9.00 will be sent to the recipient.
|
||||
pub method_fee: Decimal,
|
||||
/// Fee which we keep and don't pass to the underlying method.
|
||||
///
|
||||
/// For example, if a user withdraws $10.00 and the method takes a
|
||||
/// 10% cut, then we submit a payout request of $9.00, and the $1.00 stays
|
||||
/// in our account.
|
||||
pub platform_fee: Decimal,
|
||||
/// How much is 1 USD worth in the target currency?
|
||||
pub exchange_rate: Option<Decimal>,
|
||||
}
|
||||
|
||||
impl PayoutFees {
|
||||
pub fn total_fee(&self) -> Decimal {
|
||||
self.method_fee + self.platform_fee
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_tremendous_payout_methods(
|
||||
queue: &PayoutsQueue,
|
||||
) -> Result<Vec<PayoutMethod>> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Sku {
|
||||
min: Decimal,
|
||||
max: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ProductImageType {
|
||||
Card,
|
||||
Logo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProductImage {
|
||||
src: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: ProductImageType,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProductCountry {
|
||||
abbr: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Product {
|
||||
id: String,
|
||||
category: String,
|
||||
name: String,
|
||||
// description: String,
|
||||
// disclosure: String,
|
||||
skus: Vec<Sku>,
|
||||
currency_codes: Vec<String>,
|
||||
countries: Vec<ProductCountry>,
|
||||
images: Vec<ProductImage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousResponse {
|
||||
products: Vec<Product>,
|
||||
}
|
||||
|
||||
let response = queue
|
||||
.make_tremendous_request::<(), TremendousResponse>(
|
||||
Method::GET,
|
||||
"products",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut methods = Vec::new();
|
||||
|
||||
for product in response.products {
|
||||
const BLACKLISTED_IDS: &[&str] = &[
|
||||
// physical visa
|
||||
"A2J05SWPI2QG",
|
||||
// crypto
|
||||
"1UOOSHUUYTAM",
|
||||
"5EVJN47HPDFT",
|
||||
"NI9M4EVAVGFJ",
|
||||
"VLY29QHTMNGT",
|
||||
"7XU98H109Y3A",
|
||||
"0CGEDFP2UIKV",
|
||||
"PDYLQU0K073Y",
|
||||
"HCS5Z7O2NV5G",
|
||||
"IY1VMST1MOXS",
|
||||
"VRPZLJ7HCA8X",
|
||||
// bitcard (crypto)
|
||||
"GWQQS5RM8IZS",
|
||||
"896MYD4SGOGZ",
|
||||
"PWLEN1VZGMZA",
|
||||
"A2VRM96J5K5W",
|
||||
"HV9ICIM3JT7P",
|
||||
"K2KLSPVWC2Q4",
|
||||
"HRBRQLLTDF95",
|
||||
"UUBYLZVK7QAB",
|
||||
"BH8W3XEDEOJN",
|
||||
"7WGE043X1RYQ",
|
||||
"2B13MHUZZVTF",
|
||||
"JN6R44P86EYX",
|
||||
"DA8H43GU84SO",
|
||||
"QK2XAQHSDEH4",
|
||||
"J7K1IQFS76DK",
|
||||
"NL4JQ2G7UPRZ",
|
||||
"OEFTMSBA5ELH",
|
||||
"A3CQK6UHNV27",
|
||||
];
|
||||
const SUPPORTED_METHODS: &[&str] = &[
|
||||
"merchant_cards",
|
||||
"merchant_card",
|
||||
"bank",
|
||||
"charity",
|
||||
"paypal",
|
||||
"venmo",
|
||||
];
|
||||
|
||||
if !SUPPORTED_METHODS.contains(&&*product.category)
|
||||
|| BLACKLISTED_IDS.contains(&&*product.id)
|
||||
{
|
||||
continue;
|
||||
};
|
||||
|
||||
// https://help.tremendous.com/hc/en-us/articles/41472317536787-Premium-reward-options
|
||||
let fee = match product.category.as_str() {
|
||||
"paypal" | "venmo" => PayoutMethodFee {
|
||||
percentage: dec!(0.06),
|
||||
min: dec!(1.00),
|
||||
max: Some(dec!(25.00)),
|
||||
},
|
||||
_ => PayoutMethodFee {
|
||||
percentage: dec!(0),
|
||||
min: dec!(0),
|
||||
max: None,
|
||||
},
|
||||
};
|
||||
|
||||
let method = PayoutMethod {
|
||||
id: product.id,
|
||||
type_: PayoutMethodType::Tremendous,
|
||||
name: product.name.clone(),
|
||||
category: Some(product.category.clone()),
|
||||
supported_countries: product
|
||||
.countries
|
||||
.into_iter()
|
||||
.map(|x| x.abbr)
|
||||
.collect(),
|
||||
image_logo_url: product
|
||||
.images
|
||||
.iter()
|
||||
.find(|x| x.type_ == ProductImageType::Logo)
|
||||
.map(|x| x.src.clone()),
|
||||
image_url: product
|
||||
.images
|
||||
.into_iter()
|
||||
.find(|x| x.type_ == ProductImageType::Card)
|
||||
.map(|x| x.src),
|
||||
interval: if product.skus.len() > 1 {
|
||||
let mut values = product
|
||||
.skus
|
||||
.into_iter()
|
||||
.map(|x| PayoutDecimal(x.min))
|
||||
.collect::<Vec<_>>();
|
||||
values.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
PayoutInterval::Fixed { values }
|
||||
} else if let Some(first) = product.skus.first() {
|
||||
PayoutInterval::Standard {
|
||||
min: first.min,
|
||||
max: first.max,
|
||||
}
|
||||
} else {
|
||||
PayoutInterval::Standard {
|
||||
min: Decimal::ZERO,
|
||||
max: Decimal::from(5_000),
|
||||
}
|
||||
},
|
||||
fee,
|
||||
};
|
||||
|
||||
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
|
||||
if let PayoutInterval::Fixed { .. } = method.interval
|
||||
&& !product.currency_codes.contains(&"USD".to_string())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
methods.push(method);
|
||||
}
|
||||
|
||||
const UPRANK_IDS: &[&str] =
|
||||
&["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"];
|
||||
const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"];
|
||||
|
||||
methods.sort_by(|a, b| {
|
||||
let a_top = UPRANK_IDS.contains(&&*a.id);
|
||||
let a_bottom = DOWNRANK_IDS.contains(&&*a.id);
|
||||
let b_top = UPRANK_IDS.contains(&&*b.id);
|
||||
let b_bottom = DOWNRANK_IDS.contains(&&*b.id);
|
||||
|
||||
match (a_top, a_bottom, b_top, b_bottom) {
|
||||
(true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically
|
||||
(_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically
|
||||
(true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first
|
||||
(_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first
|
||||
(_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first
|
||||
(_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first
|
||||
(_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically
|
||||
}
|
||||
});
|
||||
|
||||
Ok(methods)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1133,6 +1371,7 @@ pub async fn insert_bank_balances_and_webhook(
|
||||
let paypal_result = PayoutsQueue::get_paypal_balance().await;
|
||||
let brex_result = PayoutsQueue::get_brex_balance().await;
|
||||
let tremendous_result = payouts.get_tremendous_balance().await;
|
||||
let mural_result = payouts.get_mural_balance().await;
|
||||
|
||||
let mut insert_account_types = Vec::new();
|
||||
let mut insert_amounts = Vec::new();
|
||||
@@ -1163,6 +1402,9 @@ pub async fn insert_bank_balances_and_webhook(
|
||||
if let Ok(Some(ref tremendous)) = tremendous_result {
|
||||
add_balance("tremendous", tremendous);
|
||||
}
|
||||
if let Ok(Some(ref mural)) = mural_result {
|
||||
add_balance("mural", mural);
|
||||
}
|
||||
|
||||
let inserted = sqlx::query_scalar!(
|
||||
r#"
|
||||
180
apps/labrinth/src/queue/payouts/mural.rs
Normal file
180
apps/labrinth/src/queue/payouts/mural.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use ariadne::ids::UserId;
|
||||
use eyre::Result;
|
||||
use muralpay::{MuralError, TokenFeeRequest};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
queue::payouts::{AccountBalance, PayoutsQueue},
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MuralPayoutRequest {
|
||||
Fiat {
|
||||
bank_name: String,
|
||||
bank_account_owner: String,
|
||||
fiat_and_rail_details: muralpay::FiatAndRailDetails,
|
||||
},
|
||||
Blockchain {
|
||||
wallet_address: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PayoutsQueue {
|
||||
pub async fn compute_muralpay_fees(
|
||||
&self,
|
||||
amount: Decimal,
|
||||
fiat_and_rail_code: muralpay::FiatAndRailCode,
|
||||
) -> Result<muralpay::TokenPayoutFee, ApiError> {
|
||||
let muralpay = self.muralpay.load();
|
||||
let muralpay = muralpay
|
||||
.as_ref()
|
||||
.wrap_internal_err("Mural Pay client not available")?;
|
||||
|
||||
let fees = muralpay
|
||||
.client
|
||||
.get_fees_for_token_amount(&[TokenFeeRequest {
|
||||
amount: muralpay::TokenAmount {
|
||||
token_symbol: muralpay::USDC.into(),
|
||||
token_amount: amount,
|
||||
},
|
||||
fiat_and_rail_code,
|
||||
}])
|
||||
.await
|
||||
.wrap_internal_err("failed to request fees")?;
|
||||
let fee = fees
|
||||
.into_iter()
|
||||
.next()
|
||||
.wrap_internal_err("no fees returned")?;
|
||||
Ok(fee)
|
||||
}
|
||||
|
||||
pub async fn create_muralpay_payout_request(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
amount: muralpay::TokenAmount,
|
||||
payout_details: MuralPayoutRequest,
|
||||
recipient_info: muralpay::PayoutRecipientInfo,
|
||||
) -> Result<muralpay::PayoutRequest, ApiError> {
|
||||
let muralpay = self.muralpay.load();
|
||||
let muralpay = muralpay
|
||||
.as_ref()
|
||||
.wrap_internal_err("Mural Pay client not available")?;
|
||||
|
||||
let payout_details = match payout_details {
|
||||
MuralPayoutRequest::Fiat {
|
||||
bank_name,
|
||||
bank_account_owner,
|
||||
fiat_and_rail_details,
|
||||
} => muralpay::CreatePayoutDetails::Fiat {
|
||||
bank_name,
|
||||
bank_account_owner,
|
||||
developer_fee: None,
|
||||
fiat_and_rail_details,
|
||||
},
|
||||
MuralPayoutRequest::Blockchain { wallet_address } => {
|
||||
muralpay::CreatePayoutDetails::Blockchain {
|
||||
wallet_details: muralpay::WalletDetails {
|
||||
// only Polygon chain is currently supported
|
||||
blockchain: muralpay::Blockchain::Polygon,
|
||||
wallet_address,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let payout = muralpay::CreatePayout {
|
||||
amount,
|
||||
payout_details,
|
||||
recipient_info,
|
||||
supporting_details: None,
|
||||
};
|
||||
|
||||
let payout_request = muralpay
|
||||
.client
|
||||
.create_payout_request(
|
||||
muralpay.source_account_id,
|
||||
Some(format!("User {user_id}")),
|
||||
&[payout],
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
MuralError::Api(err) => ApiError::Request(err.into()),
|
||||
err => ApiError::Internal(err.into()),
|
||||
})?;
|
||||
|
||||
// try to immediately execute the payout request...
|
||||
// use a poor man's try/catch block using this `async move {}`
|
||||
// to catch any errors within this block
|
||||
let result = async move {
|
||||
muralpay
|
||||
.client
|
||||
.execute_payout_request(payout_request.id)
|
||||
.await
|
||||
.wrap_internal_err("failed to execute payout request")?;
|
||||
eyre::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// and if it fails, make sure to immediately cancel it -
|
||||
// we don't want floating payout requests
|
||||
if let Err(err) = result {
|
||||
muralpay
|
||||
.client
|
||||
.cancel_payout_request(payout_request.id)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to cancel unexecuted payout request",
|
||||
)?;
|
||||
return Err(ApiError::Internal(err));
|
||||
}
|
||||
|
||||
Ok(payout_request)
|
||||
}
|
||||
|
||||
pub async fn cancel_muralpay_payout_request(
|
||||
&self,
|
||||
id: muralpay::PayoutRequestId,
|
||||
) -> Result<()> {
|
||||
let muralpay = self.muralpay.load();
|
||||
let muralpay = muralpay
|
||||
.as_ref()
|
||||
.wrap_err("Mural Pay client not available")?;
|
||||
|
||||
muralpay.client.cancel_payout_request(id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_mural_balance(&self) -> Result<Option<AccountBalance>> {
|
||||
let muralpay = self.muralpay.load();
|
||||
let muralpay = muralpay
|
||||
.as_ref()
|
||||
.wrap_err("Mural Pay client not available")?;
|
||||
|
||||
let account = muralpay
|
||||
.client
|
||||
.get_account(muralpay.source_account_id)
|
||||
.await?;
|
||||
let details = account
|
||||
.account_details
|
||||
.wrap_err("source account does not have details")?;
|
||||
let available = details
|
||||
.balances
|
||||
.iter()
|
||||
.map(|balance| {
|
||||
if balance.token_symbol == muralpay::USDC {
|
||||
balance.token_amount
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
}
|
||||
})
|
||||
.sum::<Decimal>();
|
||||
Ok(Some(AccountBalance {
|
||||
available,
|
||||
pending: Decimal::ZERO,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod gdpr;
|
||||
pub mod gotenberg;
|
||||
pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod mural;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
pub mod statuses;
|
||||
@@ -31,6 +32,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config)
|
||||
.configure(external_notifications::config)
|
||||
.configure(affiliate::config),
|
||||
.configure(affiliate::config)
|
||||
.configure(mural::config),
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/labrinth/src/routes/internal/mural.rs
Normal file
28
apps/labrinth/src/routes/internal/mural.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use actix_web::{get, web};
|
||||
use muralpay::FiatAndRailCode;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::{
|
||||
queue::payouts::PayoutsQueue, routes::ApiError, util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get_bank_details);
|
||||
}
|
||||
|
||||
#[get("/mural/bank-details")]
|
||||
async fn get_bank_details(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<web::Json<muralpay::BankDetailsResponse>, ApiError> {
|
||||
let mural = payouts_queue.muralpay.load();
|
||||
let mural = mural
|
||||
.as_ref()
|
||||
.wrap_internal_err("Mural API not available")?;
|
||||
let fiat_and_rail_codes = FiatAndRailCode::iter().collect::<Vec<_>>();
|
||||
let details = mural
|
||||
.client
|
||||
.get_bank_details(&fiat_and_rail_codes)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch bank details")?;
|
||||
Ok(web::Json(details))
|
||||
}
|
||||
@@ -85,12 +85,18 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Error when calling an HTTP endpoint.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ApiError {
|
||||
/// Error occurred on the server side, which the caller has no fault in.
|
||||
#[error(transparent)]
|
||||
Internal(eyre::Report),
|
||||
/// Caller made an invalid or malformed request.
|
||||
#[error(transparent)]
|
||||
Request(eyre::Report),
|
||||
/// Caller attempted a request which they are not allowed to make.
|
||||
#[error(transparent)]
|
||||
Auth(eyre::Report),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("Environment error")]
|
||||
@@ -161,41 +167,47 @@ impl ApiError {
|
||||
pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> {
|
||||
crate::models::error::ApiError {
|
||||
error: match self {
|
||||
ApiError::Internal(..) => "internal_error",
|
||||
Self::Internal(..) => "internal_error",
|
||||
Self::Request(..) => "request_error",
|
||||
ApiError::Env(..) => "environment_error",
|
||||
ApiError::Database(..) => "database_error",
|
||||
ApiError::SqlxDatabase(..) => "database_error",
|
||||
ApiError::RedisDatabase(..) => "database_error",
|
||||
ApiError::Authentication(..) => "unauthorized",
|
||||
ApiError::CustomAuthentication(..) => "unauthorized",
|
||||
ApiError::Xml(..) => "xml_error",
|
||||
ApiError::Json(..) => "json_error",
|
||||
ApiError::Search(..) => "search_error",
|
||||
ApiError::Indexing(..) => "indexing_error",
|
||||
ApiError::FileHosting(..) => "file_hosting_error",
|
||||
ApiError::InvalidInput(..) => "invalid_input",
|
||||
ApiError::Validation(..) => "invalid_input",
|
||||
ApiError::Payments(..) => "payments_error",
|
||||
ApiError::Discord(..) => "discord_error",
|
||||
ApiError::Turnstile => "turnstile_error",
|
||||
ApiError::Decoding(..) => "decoding_error",
|
||||
ApiError::ImageParse(..) => "invalid_image",
|
||||
ApiError::PasswordHashing(..) => "password_hashing_error",
|
||||
ApiError::Mail(..) => "mail_error",
|
||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||
ApiError::Reroute(..) => "reroute_error",
|
||||
ApiError::NotFound => "not_found",
|
||||
ApiError::Conflict(..) => "conflict",
|
||||
ApiError::TaxComplianceApi => "tax_compliance_api_error",
|
||||
ApiError::Zip(..) => "zip_error",
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
ApiError::Stripe(..) => "stripe_error",
|
||||
ApiError::TaxProcessor(..) => "tax_processor_error",
|
||||
ApiError::Slack(..) => "slack_error",
|
||||
Self::Auth(..) => "auth_error",
|
||||
Self::Env(..) => "environment_error",
|
||||
Self::Database(..) => "database_error",
|
||||
Self::SqlxDatabase(..) => "database_error",
|
||||
Self::RedisDatabase(..) => "database_error",
|
||||
Self::Authentication(..) => "unauthorized",
|
||||
Self::CustomAuthentication(..) => "unauthorized",
|
||||
Self::Xml(..) => "xml_error",
|
||||
Self::Json(..) => "json_error",
|
||||
Self::Search(..) => "search_error",
|
||||
Self::Indexing(..) => "indexing_error",
|
||||
Self::FileHosting(..) => "file_hosting_error",
|
||||
Self::InvalidInput(..) => "invalid_input",
|
||||
Self::Validation(..) => "invalid_input",
|
||||
Self::Payments(..) => "payments_error",
|
||||
Self::Discord(..) => "discord_error",
|
||||
Self::Turnstile => "turnstile_error",
|
||||
Self::Decoding(..) => "decoding_error",
|
||||
Self::ImageParse(..) => "invalid_image",
|
||||
Self::PasswordHashing(..) => "password_hashing_error",
|
||||
Self::Mail(..) => "mail_error",
|
||||
Self::Clickhouse(..) => "clickhouse_error",
|
||||
Self::Reroute(..) => "reroute_error",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Conflict(..) => "conflict",
|
||||
Self::TaxComplianceApi => "tax_compliance_api_error",
|
||||
Self::Zip(..) => "zip_error",
|
||||
Self::Io(..) => "io_error",
|
||||
Self::RateLimitError(..) => "ratelimit_error",
|
||||
Self::Stripe(..) => "stripe_error",
|
||||
Self::TaxProcessor(..) => "tax_processor_error",
|
||||
Self::Slack(..) => "slack_error",
|
||||
},
|
||||
description: match self {
|
||||
Self::Internal(e) => format!("{e:#?}"),
|
||||
Self::Request(e) => format!("{e:#?}"),
|
||||
Self::Auth(e) => format!("{e:#?}"),
|
||||
_ => self.to_string(),
|
||||
},
|
||||
description: self.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,39 +215,40 @@ impl ApiError {
|
||||
impl actix_web::ResponseError for ApiError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Request(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
|
||||
ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Json(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Validation(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::Turnstile => StatusCode::BAD_REQUEST,
|
||||
ApiError::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::ImageParse(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||
ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Request(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Auth(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Json(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Validation(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Payments(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::Discord(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::Turnstile => StatusCode::BAD_REQUEST,
|
||||
Self::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
Self::ImageParse(..) => StatusCode::BAD_REQUEST,
|
||||
Self::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::Conflict(..) => StatusCode::CONFLICT,
|
||||
Self::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Io(..) => StatusCode::BAD_REQUEST,
|
||||
Self::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(threads::config)
|
||||
.configure(users::config)
|
||||
.configure(version_file::config)
|
||||
.configure(payouts::config)
|
||||
.configure(versions::config)
|
||||
.configure(friends::config),
|
||||
);
|
||||
@@ -61,6 +60,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_get::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/payout")
|
||||
.wrap(default_cors())
|
||||
.configure(payouts::config),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn hello_world() -> Result<HttpResponse, ApiError> {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::{AuthenticationError, get_user_from_headers};
|
||||
use crate::database::models::DBUserId;
|
||||
use crate::database::models::payout_item::DBPayout;
|
||||
use crate::database::models::{DBPayoutId, DBUser, DBUserId};
|
||||
use crate::database::models::{generate_payout_id, users_compliance};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::PayoutId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::payouts::{PayoutMethodType, PayoutStatus};
|
||||
use crate::models::payouts::{
|
||||
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
||||
TremendousDetails, TremendousForexResponse,
|
||||
};
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
@@ -13,6 +17,7 @@ use crate::util::avalara1099;
|
||||
use crate::util::error::Context;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use eyre::eyre;
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::Method;
|
||||
@@ -28,38 +33,26 @@ use tracing::error;
|
||||
const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration =
|
||||
chrono::Duration::seconds(15);
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("payout")
|
||||
.service(paypal_webhook)
|
||||
.service(tremendous_webhook)
|
||||
// we use `route` instead of `service` because `user_payouts` uses the logic of `transaction_history`
|
||||
.route(
|
||||
"",
|
||||
web::get().to(
|
||||
#[expect(
|
||||
deprecated,
|
||||
reason = "v3 backwards compatibility"
|
||||
)]
|
||||
user_payouts,
|
||||
),
|
||||
)
|
||||
.route("history", web::get().to(transaction_history))
|
||||
.service(create_payout)
|
||||
.service(cancel_payout)
|
||||
.service(payment_methods)
|
||||
.service(get_balance)
|
||||
.service(platform_revenue)
|
||||
.service(post_compliance_form),
|
||||
);
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(paypal_webhook)
|
||||
.service(tremendous_webhook)
|
||||
.service(transaction_history)
|
||||
.service(calculate_fees)
|
||||
.service(create_payout)
|
||||
.service(cancel_payout)
|
||||
.service(payment_methods)
|
||||
.service(get_balance)
|
||||
.service(platform_revenue)
|
||||
.service(post_compliance_form);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct RequestForm {
|
||||
form_type: users_compliance::FormType,
|
||||
}
|
||||
|
||||
#[post("compliance")]
|
||||
#[utoipa::path]
|
||||
#[post("/compliance")]
|
||||
pub async fn post_compliance_form(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -157,7 +150,8 @@ pub async fn post_compliance_form(
|
||||
}
|
||||
}
|
||||
|
||||
#[post("_paypal")]
|
||||
#[utoipa::path]
|
||||
#[post("/_paypal")]
|
||||
pub async fn paypal_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -314,7 +308,8 @@ pub async fn paypal_webhook(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("_tremendous")]
|
||||
#[utoipa::path]
|
||||
#[post("/_tremendous")]
|
||||
pub async fn tremendous_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -424,60 +419,55 @@ pub async fn tremendous_webhook(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[deprecated = "use `transaction_history` instead"]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<Vec<crate::models::payouts::Payout>>, ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PAYOUTS_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let items = transaction_history(req, pool, redis, session_queue)
|
||||
.await?
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|txn_item| match txn_item {
|
||||
TransactionItem::Withdrawal {
|
||||
id,
|
||||
status,
|
||||
created,
|
||||
amount,
|
||||
fee,
|
||||
method_type,
|
||||
method_address,
|
||||
} => Some(crate::models::payouts::Payout {
|
||||
id,
|
||||
user_id: user.id,
|
||||
status,
|
||||
created,
|
||||
amount,
|
||||
fee,
|
||||
method: method_type,
|
||||
method_address,
|
||||
platform_id: None,
|
||||
}),
|
||||
TransactionItem::PayoutAvailable { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(web::Json(items))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct Withdrawal {
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
amount: Decimal,
|
||||
method: PayoutMethodType,
|
||||
#[serde(flatten)]
|
||||
method: PayoutMethodRequest,
|
||||
method_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WithdrawalFees {
|
||||
pub fee: Decimal,
|
||||
pub exchange_rate: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/fees")]
|
||||
pub async fn calculate_fees(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<web::Json<WithdrawalFees>, ApiError> {
|
||||
// even though we don't use the user, we ensure they're logged in to make API calls
|
||||
let (_, _user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
None,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::Authentication(AuthenticationError::InvalidCredentials)
|
||||
})?;
|
||||
|
||||
let fees = payouts_queue
|
||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
||||
.await?;
|
||||
|
||||
Ok(web::Json(WithdrawalFees {
|
||||
fee: fees.total_fee(),
|
||||
exchange_rate: fees.exchange_rate,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("")]
|
||||
pub async fn create_payout(
|
||||
req: HttpRequest,
|
||||
@@ -486,7 +476,7 @@ pub async fn create_payout(
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<(), ApiError> {
|
||||
let (scopes, user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
None,
|
||||
@@ -514,9 +504,12 @@ pub async fn create_payout(
|
||||
user.id.0
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch user balance")?;
|
||||
|
||||
let balance = get_user_balance(user.id, &pool).await?;
|
||||
let balance = get_user_balance(user.id, &pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to calculate user balance")?;
|
||||
if balance.available < body.amount || body.amount < Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
@@ -585,255 +578,372 @@ pub async fn create_payout(
|
||||
));
|
||||
}
|
||||
|
||||
let payout_method = payouts_queue
|
||||
.get_payout_methods()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|x| x.id == body.method_id)
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Invalid payment method specified!".to_string(),
|
||||
)
|
||||
})?;
|
||||
let fees = payouts_queue
|
||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
||||
.await?;
|
||||
|
||||
let fee = std::cmp::min(
|
||||
std::cmp::max(
|
||||
payout_method.fee.min,
|
||||
payout_method.fee.percentage * body.amount,
|
||||
),
|
||||
payout_method.fee.max.unwrap_or(Decimal::MAX),
|
||||
);
|
||||
// fees are a bit complicated here, since we have 2 types:
|
||||
// - method fees - this is what Tremendous, Mural, etc. will take from us
|
||||
// without us having a say in it
|
||||
// - platform fees - this is what we deliberately keep for ourselves
|
||||
// - total fees - method fees + platform fees
|
||||
//
|
||||
// we first make sure that `amount - total fees` is greater than zero,
|
||||
// then we issue a payout request with `amount - platform fees`
|
||||
|
||||
let transfer = (body.amount - fee).round_dp(2);
|
||||
if transfer <= Decimal::ZERO {
|
||||
if (body.amount - fees.total_fee()).round_dp(2) <= Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You need to withdraw more to cover the fee!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let payout_id = generate_payout_id(&mut transaction).await?;
|
||||
let sent_to_method = (body.amount - fees.platform_fee).round_dp(2);
|
||||
assert!(sent_to_method > Decimal::ZERO);
|
||||
|
||||
let payout_item = match body.method {
|
||||
PayoutMethodType::Venmo | PayoutMethodType::PayPal => {
|
||||
let (wallet, wallet_type, address, display_address) = if body.method
|
||||
== PayoutMethodType::Venmo
|
||||
{
|
||||
if let Some(venmo) = user.venmo_handle {
|
||||
("Venmo", "user_handle", venmo.clone(), venmo)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Venmo address has not been set for account!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else if let Some(paypal_id) = user.paypal_id {
|
||||
if let Some(paypal_country) = user.paypal_country {
|
||||
if &*paypal_country == "US"
|
||||
&& &*body.method_id != "paypal_us"
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the US PayPal transfer option!"
|
||||
.to_string(),
|
||||
));
|
||||
} else if &*paypal_country != "US"
|
||||
&& &*body.method_id == "paypal_us"
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the International PayPal transfer option!".to_string(),
|
||||
));
|
||||
}
|
||||
let payout_id = generate_payout_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate payout ID")?;
|
||||
|
||||
(
|
||||
"PayPal",
|
||||
"paypal_id",
|
||||
paypal_id.clone(),
|
||||
user.paypal_email.unwrap_or(paypal_id),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please re-link your PayPal account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You have not linked a PayPal account!".to_string(),
|
||||
));
|
||||
};
|
||||
let payout_cx = PayoutContext {
|
||||
body: &body,
|
||||
user: &user,
|
||||
payout_id,
|
||||
raw_amount: body.amount,
|
||||
total_fee: fees.total_fee(),
|
||||
sent_to_method,
|
||||
payouts_queue: &payouts_queue,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
let mut payout_item =
|
||||
crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: transfer,
|
||||
fee: Some(fee),
|
||||
method: Some(body.method),
|
||||
method_address: Some(display_address),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
let res: PayoutsResponse = payouts_queue.make_paypal_request(
|
||||
Method::POST,
|
||||
"payments/payouts",
|
||||
Some(
|
||||
json! ({
|
||||
"sender_batch_header": {
|
||||
"sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()),
|
||||
"email_subject": "You have received a payment from Modrinth!",
|
||||
"email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.",
|
||||
},
|
||||
"items": [{
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": transfer.to_string()
|
||||
},
|
||||
"receiver": address,
|
||||
"note": "Payment from Modrinth creator monetization program",
|
||||
"recipient_type": wallet_type,
|
||||
"recipient_wallet": wallet,
|
||||
"sender_item_id": crate::models::ids::PayoutId::from(payout_id),
|
||||
}]
|
||||
})
|
||||
),
|
||||
None,
|
||||
None
|
||||
).await?;
|
||||
|
||||
if let Some(link) = res.links.first() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutItem {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutItem>,
|
||||
}
|
||||
|
||||
if let Ok(res) = payouts_queue
|
||||
.make_paypal_request::<(), PayoutData>(
|
||||
Method::GET,
|
||||
&link.href,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
&& let Some(data) = res.items.first()
|
||||
{
|
||||
payout_item.platform_id = Some(data.payout_item_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
payout_item
|
||||
let payout_item = match &body.method {
|
||||
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
|
||||
paypal_payout(payout_cx).await?
|
||||
}
|
||||
PayoutMethodType::Tremendous => {
|
||||
if let Some(email) = user.email {
|
||||
if user.email_verified {
|
||||
let mut payout_item =
|
||||
crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: transfer,
|
||||
fee: Some(fee),
|
||||
method: Some(PayoutMethodType::Tremendous),
|
||||
method_address: Some(email.clone()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reward {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Order {
|
||||
pub rewards: Vec<Reward>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousResponse {
|
||||
pub order: Order,
|
||||
}
|
||||
|
||||
let res: TremendousResponse = payouts_queue
|
||||
.make_tremendous_request(
|
||||
Method::POST,
|
||||
"orders",
|
||||
Some(json! ({
|
||||
"payment": {
|
||||
"funding_source_id": "BALANCE",
|
||||
},
|
||||
"rewards": [{
|
||||
"value": {
|
||||
"denomination": transfer
|
||||
},
|
||||
"delivery": {
|
||||
"method": "EMAIL"
|
||||
},
|
||||
"recipient": {
|
||||
"name": user.username,
|
||||
"email": email
|
||||
},
|
||||
"products": [
|
||||
&body.method_id,
|
||||
],
|
||||
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reward) = res.order.rewards.first() {
|
||||
payout_item.platform_id = Some(reward.id.clone())
|
||||
}
|
||||
|
||||
payout_item
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must verify your account email to proceed!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must add an email to your account to proceed!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
PayoutMethodRequest::Tremendous { method_details } => {
|
||||
tremendous_payout(payout_cx, method_details).await?
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::Payments(
|
||||
"Invalid payment method specified!".to_string(),
|
||||
));
|
||||
PayoutMethodRequest::MuralPay { method_details } => {
|
||||
mural_pay_payout(payout_cx, method_details).await?
|
||||
}
|
||||
};
|
||||
|
||||
payout_item.insert(&mut transaction).await?;
|
||||
payout_item
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert payout")?;
|
||||
|
||||
transaction.commit().await?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear user caches")?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct PayoutContext<'a> {
|
||||
body: &'a Withdrawal,
|
||||
user: &'a DBUser,
|
||||
payout_id: DBPayoutId,
|
||||
raw_amount: Decimal,
|
||||
total_fee: Decimal,
|
||||
sent_to_method: Decimal,
|
||||
payouts_queue: &'a PayoutsQueue,
|
||||
}
|
||||
|
||||
fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> {
|
||||
let email = user.email.as_ref().wrap_request_err(
|
||||
"you must add an email to your account to withdraw",
|
||||
)?;
|
||||
if !user.email_verified {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"you must verify your email to withdraw"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
async fn tremendous_payout(
|
||||
PayoutContext {
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
TremendousDetails {
|
||||
delivery_email,
|
||||
currency,
|
||||
}: &TremendousDetails,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let user_email = get_verified_email(user)?;
|
||||
|
||||
let mut payout_item = DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(PayoutMethodType::Tremendous),
|
||||
method_address: Some(user_email.to_string()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reward {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Order {
|
||||
pub rewards: Vec<Reward>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousResponse {
|
||||
pub order: Order,
|
||||
}
|
||||
|
||||
let forex: TremendousForexResponse = payouts_queue
|
||||
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch Tremendous forex data")?;
|
||||
|
||||
let (denomination, currency_code) = if let Some(currency) = currency {
|
||||
let currency_code = currency.to_string();
|
||||
let exchange_rate =
|
||||
forex.forex.get(¤cy_code).wrap_internal_err_with(|| {
|
||||
eyre!("no Tremendous forex data for {currency}")
|
||||
})?;
|
||||
(sent_to_method * *exchange_rate, Some(currency_code))
|
||||
} else {
|
||||
(sent_to_method, None)
|
||||
};
|
||||
|
||||
let reward_value = if let Some(currency_code) = currency_code {
|
||||
json!({
|
||||
"denomination": denomination,
|
||||
"currency_code": currency_code,
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"denomination": denomination,
|
||||
})
|
||||
};
|
||||
|
||||
let res: TremendousResponse = payouts_queue
|
||||
.make_tremendous_request(
|
||||
Method::POST,
|
||||
"orders",
|
||||
Some(json! ({
|
||||
"payment": {
|
||||
"funding_source_id": "BALANCE",
|
||||
},
|
||||
"rewards": [{
|
||||
"value": reward_value,
|
||||
"delivery": {
|
||||
"method": "EMAIL"
|
||||
},
|
||||
"recipient": {
|
||||
"name": user.username,
|
||||
"email": delivery_email
|
||||
},
|
||||
"products": [
|
||||
&body.method_id,
|
||||
],
|
||||
"campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?,
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reward) = res.order.rewards.first() {
|
||||
payout_item.platform_id = Some(reward.id.clone())
|
||||
}
|
||||
|
||||
Ok(payout_item)
|
||||
}
|
||||
|
||||
async fn mural_pay_payout(
|
||||
PayoutContext {
|
||||
body: _body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
details: &MuralPayDetails,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let user_email = get_verified_email(user)?;
|
||||
|
||||
let payout_request = payouts_queue
|
||||
.create_muralpay_payout_request(
|
||||
user.id.into(),
|
||||
muralpay::TokenAmount {
|
||||
token_symbol: muralpay::USDC.into(),
|
||||
token_amount: sent_to_method,
|
||||
},
|
||||
details.payout_details.clone(),
|
||||
details.recipient_info.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::Success,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(PayoutMethodType::MuralPay),
|
||||
method_address: Some(user_email.to_string()),
|
||||
platform_id: Some(payout_request.id.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn paypal_payout(
|
||||
PayoutContext {
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let (wallet, wallet_type, address, display_address) =
|
||||
if matches!(body.method, PayoutMethodRequest::Venmo) {
|
||||
if let Some(venmo) = &user.venmo_handle {
|
||||
("Venmo", "user_handle", venmo.clone(), venmo)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Venmo address has not been set for account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else if let Some(paypal_id) = &user.paypal_id {
|
||||
if let Some(paypal_country) = &user.paypal_country {
|
||||
if paypal_country == "US" && &*body.method_id != "paypal_us" {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the US PayPal transfer option!".to_string(),
|
||||
));
|
||||
} else if paypal_country != "US"
|
||||
&& &*body.method_id == "paypal_us"
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please use the International PayPal transfer option!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
(
|
||||
"PayPal",
|
||||
"paypal_id",
|
||||
paypal_id.clone(),
|
||||
user.paypal_email.as_ref().unwrap_or(paypal_id),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please re-link your PayPal account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You have not linked a PayPal account!".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
let mut payout_item = crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(body.method.method_type()),
|
||||
method_address: Some(display_address.clone()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
let res: PayoutsResponse = payouts_queue.make_paypal_request(
|
||||
Method::POST,
|
||||
"payments/payouts",
|
||||
Some(
|
||||
json!({
|
||||
"sender_batch_header": {
|
||||
"sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()),
|
||||
"email_subject": "You have received a payment from Modrinth!",
|
||||
"email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.",
|
||||
},
|
||||
"items": [{
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": sent_to_method.to_string()
|
||||
},
|
||||
"receiver": address,
|
||||
"note": "Payment from Modrinth creator monetization program",
|
||||
"recipient_type": wallet_type,
|
||||
"recipient_wallet": wallet,
|
||||
"sender_item_id": crate::models::ids::PayoutId::from(payout_id),
|
||||
}]
|
||||
})
|
||||
),
|
||||
None,
|
||||
None
|
||||
).await?;
|
||||
|
||||
if let Some(link) = res.links.first() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutItem {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutItem>,
|
||||
}
|
||||
|
||||
if let Ok(res) = payouts_queue
|
||||
.make_paypal_request::<(), PayoutData>(
|
||||
Method::GET,
|
||||
&link.href,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
&& let Some(data) = res.items.first()
|
||||
{
|
||||
payout_item.platform_id = Some(data.payout_item_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(payout_item)
|
||||
}
|
||||
|
||||
/// User performing a payout-related action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TransactionItem {
|
||||
/// User withdrew some of their available payout.
|
||||
Withdrawal {
|
||||
id: PayoutId,
|
||||
status: PayoutStatus,
|
||||
@@ -843,6 +953,7 @@ pub enum TransactionItem {
|
||||
method_type: Option<PayoutMethodType>,
|
||||
method_address: Option<String>,
|
||||
},
|
||||
/// User got a payout available for them to withdraw.
|
||||
PayoutAvailable {
|
||||
created: DateTime<Utc>,
|
||||
payout_source: PayoutSource,
|
||||
@@ -859,7 +970,17 @@ impl TransactionItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum PayoutSource {
|
||||
@@ -867,6 +988,10 @@ pub enum PayoutSource {
|
||||
Affilites,
|
||||
}
|
||||
|
||||
/// Get the history of when the authorized user got payouts available, and when
|
||||
/// the user withdrew their payouts.
|
||||
#[utoipa::path(responses((status = OK, body = Vec<TransactionItem>)))]
|
||||
#[get("/history")]
|
||||
pub async fn transaction_history(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -907,7 +1032,7 @@ pub async fn transaction_history(
|
||||
});
|
||||
|
||||
let mut payouts_available = sqlx::query!(
|
||||
"SELECT created, amount
|
||||
"SELECT date_available, amount
|
||||
FROM payouts_values
|
||||
WHERE user_id = $1
|
||||
AND NOW() >= date_available",
|
||||
@@ -918,7 +1043,7 @@ pub async fn transaction_history(
|
||||
let record = record
|
||||
.wrap_internal_err("failed to fetch available payout record")?;
|
||||
Ok(TransactionItem::PayoutAvailable {
|
||||
created: record.created,
|
||||
created: record.date_available,
|
||||
payout_source: PayoutSource::CreatorRewards,
|
||||
amount: record.amount,
|
||||
})
|
||||
@@ -935,7 +1060,8 @@ pub async fn transaction_history(
|
||||
Ok(web::Json(txn_items))
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}")]
|
||||
pub async fn cancel_payout(
|
||||
info: web::Path<(PayoutId,)>,
|
||||
req: HttpRequest,
|
||||
@@ -995,10 +1121,16 @@ pub async fn cancel_payout(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
));
|
||||
PayoutMethodType::MuralPay => {
|
||||
let payout_request_id = platform_id
|
||||
.parse::<muralpay::PayoutRequestId>()
|
||||
.wrap_request_err("invalid payout request ID")?;
|
||||
payouts
|
||||
.cancel_muralpay_payout_request(payout_request_id)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to cancel payout request",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,7 +1179,8 @@ pub enum FormCompletionStatus {
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[get("methods")]
|
||||
#[utoipa::path]
|
||||
#[get("/methods")]
|
||||
pub async fn payment_methods(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
filter: web::Query<MethodFilter>,
|
||||
@@ -1079,7 +1212,8 @@ pub struct UserBalance {
|
||||
pub dates: HashMap<DateTime<Utc>, Decimal>,
|
||||
}
|
||||
|
||||
#[get("balance")]
|
||||
#[utoipa::path]
|
||||
#[get("/balance")]
|
||||
pub async fn get_balance(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1217,7 +1351,9 @@ async fn update_compliance_status(
|
||||
user_id: crate::database::models::ids::DBUserId,
|
||||
) -> Result<Option<ComplianceCheck>, ApiError> {
|
||||
let maybe_compliance =
|
||||
users_compliance::UserCompliance::get_by_user_id(pg, user_id).await?;
|
||||
users_compliance::UserCompliance::get_by_user_id(pg, user_id)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch user tax compliance")?;
|
||||
|
||||
let Some(mut compliance) = maybe_compliance else {
|
||||
return Ok(None);
|
||||
@@ -1233,7 +1369,9 @@ async fn update_compliance_status(
|
||||
compliance_api_check_failed: false,
|
||||
}))
|
||||
} else {
|
||||
let result = avalara1099::check_form(&compliance.reference_id).await?;
|
||||
let result = avalara1099::check_form(&compliance.reference_id)
|
||||
.await
|
||||
.wrap_internal_err("failed to check form using Track1099")?;
|
||||
let mut compliance_api_check_failed = false;
|
||||
|
||||
compliance.last_checked = Utc::now();
|
||||
@@ -1311,7 +1449,8 @@ pub struct RevenueData {
|
||||
pub creator_revenue: Decimal,
|
||||
}
|
||||
|
||||
#[get("platform_revenue")]
|
||||
#[utoipa::path]
|
||||
#[get("/platform_revenue")]
|
||||
pub async fn platform_revenue(
|
||||
query: web::Query<RevenueQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use eyre::{Context, eyre};
|
||||
|
||||
pub fn env_var(key: &str) -> eyre::Result<String> {
|
||||
dotenvy::var(key)
|
||||
.wrap_err_with(|| eyre!("missing environment variable `{key}`"))
|
||||
}
|
||||
|
||||
pub fn parse_var<T: FromStr>(var: &str) -> Option<T> {
|
||||
dotenvy::var(var).ok().and_then(|i| i.parse().ok())
|
||||
}
|
||||
|
||||
@@ -5,111 +5,253 @@ use std::{
|
||||
|
||||
use crate::routes::ApiError;
|
||||
|
||||
/// Allows wrapping [`Result`]s and [`Option`]s into [`Result<T, ApiError>`]s.
|
||||
#[allow(
|
||||
clippy::missing_errors_doc,
|
||||
reason = "this trait's purpose is improving error handling"
|
||||
)]
|
||||
pub trait Context<T, E>: Sized {
|
||||
fn wrap_request_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
/// Maps the error variant into an [`eyre::Report`], creating the message
|
||||
/// using `f`.
|
||||
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static;
|
||||
D: Send + Sync + Debug + Display + 'static;
|
||||
|
||||
fn wrap_request_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||
/// Maps the error variant into an [`eyre::Report`] with the given message.
|
||||
#[inline]
|
||||
fn wrap_err<D>(self, msg: D) -> Result<T, eyre::Report>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static,
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_request_err_with(|| msg)
|
||||
self.wrap_err_with(|| msg)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Internal`] using the closure to create the message.
|
||||
#[inline]
|
||||
fn wrap_internal_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static;
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_err_with(f).map_err(ApiError::Internal)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Internal`] with the given message.
|
||||
#[inline]
|
||||
fn wrap_internal_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static,
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_internal_err_with(|| msg)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Request`] using the closure to create the message.
|
||||
#[inline]
|
||||
fn wrap_request_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_err_with(f).map_err(ApiError::Request)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Request`] with the given message.
|
||||
#[inline]
|
||||
fn wrap_request_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||
where
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_request_err_with(|| msg)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Auth`] using the closure to create the message.
|
||||
#[inline]
|
||||
fn wrap_auth_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
|
||||
where
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_err_with(f).map_err(ApiError::Auth)
|
||||
}
|
||||
|
||||
/// Maps the error variant into an [`ApiError::Auth`] with the given message.
|
||||
#[inline]
|
||||
fn wrap_auth_err<D>(self, msg: D) -> Result<T, ApiError>
|
||||
where
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.wrap_auth_err_with(|| msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E> Context<T, E> for Result<T, E>
|
||||
where
|
||||
E: std::error::Error + Send + Sync + Sized + 'static,
|
||||
Self: eyre::WrapErr<T, E>,
|
||||
{
|
||||
fn wrap_request_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||
where
|
||||
D: Display + Send + Sync + 'static,
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.map_err(|err| {
|
||||
let report = eyre::Report::new(err).wrap_err(f());
|
||||
ApiError::Request(report)
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_internal_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
D: Display + Send + Sync + 'static,
|
||||
{
|
||||
self.map_err(|err| {
|
||||
let report = eyre::Report::new(err).wrap_err(f());
|
||||
ApiError::Internal(report)
|
||||
})
|
||||
eyre::WrapErr::wrap_err_with(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Context<T, Infallible> for Option<T> {
|
||||
fn wrap_request_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static,
|
||||
D: Send + Sync + Debug + Display + 'static,
|
||||
{
|
||||
self.ok_or_else(|| ApiError::Request(eyre::Report::msg(f())))
|
||||
}
|
||||
|
||||
fn wrap_internal_err_with<D>(
|
||||
self,
|
||||
f: impl FnOnce() -> D,
|
||||
) -> Result<T, ApiError>
|
||||
where
|
||||
D: Debug + Display + Send + Sync + 'static,
|
||||
{
|
||||
self.ok_or_else(|| ApiError::Internal(eyre::Report::msg(f())))
|
||||
self.ok_or_else(|| eyre::Report::msg(f()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_web::{ResponseError, http::StatusCode};
|
||||
|
||||
fn sqlx_result() -> Result<(), sqlx::Error> {
|
||||
Err(sqlx::Error::RowNotFound)
|
||||
}
|
||||
|
||||
// these just test that code written with the above API compiles
|
||||
fn propagating() -> Result<(), ApiError> {
|
||||
sqlx_result()
|
||||
.wrap_internal_err("failed to perform database operation")?;
|
||||
sqlx_result().wrap_request_err("invalid request parameter")?;
|
||||
|
||||
None::<()>.wrap_internal_err("something is missing")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// just so we don't get a dead code warning
|
||||
#[test]
|
||||
fn test_propagating() {
|
||||
_ = propagating();
|
||||
fn test_api_error_display() {
|
||||
let error = ApiError::Internal(eyre::eyre!("test internal error"));
|
||||
assert!(error.to_string().contains("test internal error"));
|
||||
|
||||
let error = ApiError::Request(eyre::eyre!("test request error"));
|
||||
assert!(error.to_string().contains("test request error"));
|
||||
|
||||
let error = ApiError::Auth(eyre::eyre!("test auth error"));
|
||||
assert!(error.to_string().contains("test auth error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_debug() {
|
||||
let error = ApiError::Internal(eyre::eyre!("test error"));
|
||||
let debug_str = format!("{error:?}");
|
||||
assert!(debug_str.contains("Internal"));
|
||||
assert!(debug_str.contains("test error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_error_status_codes() {
|
||||
let internal_error = ApiError::Internal(eyre::eyre!("internal error"));
|
||||
assert_eq!(
|
||||
internal_error.status_code(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
|
||||
let request_error = ApiError::Request(eyre::eyre!("request error"));
|
||||
assert_eq!(request_error.status_code(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let auth_error = ApiError::Auth(eyre::eyre!("auth error"));
|
||||
assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_error_response() {
|
||||
let error = ApiError::Request(eyre::eyre!("test request error"));
|
||||
let response = error.error_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Skip the body parsing test as it requires async and is more complex
|
||||
// The important thing is that the error response is created correctly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_result() {
|
||||
let result: Result<i32, std::io::Error> = Ok(42);
|
||||
let wrapped = result.wrap_err("context message");
|
||||
assert_eq!(wrapped.unwrap(), 42);
|
||||
|
||||
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"not found",
|
||||
));
|
||||
let wrapped = result.wrap_err("context message");
|
||||
assert!(wrapped.is_err());
|
||||
assert!(wrapped.unwrap_err().to_string().contains("context message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_option() {
|
||||
let option: Option<i32> = Some(42);
|
||||
let wrapped = option.wrap_err("context message");
|
||||
assert_eq!(wrapped.unwrap(), 42);
|
||||
|
||||
let option: Option<i32> = None;
|
||||
let wrapped = option.wrap_err("context message");
|
||||
assert!(wrapped.is_err());
|
||||
assert_eq!(wrapped.unwrap_err().to_string(), "context message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_internal_error() {
|
||||
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"not found",
|
||||
));
|
||||
let wrapped = result.wrap_internal_err("internal error context");
|
||||
|
||||
assert!(wrapped.is_err());
|
||||
match wrapped.unwrap_err() {
|
||||
ApiError::Internal(report) => {
|
||||
assert!(report.to_string().contains("internal error context"));
|
||||
}
|
||||
_ => panic!("Expected Internal error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_request_error() {
|
||||
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"not found",
|
||||
));
|
||||
let wrapped = result.wrap_request_err("request error context");
|
||||
|
||||
assert!(wrapped.is_err());
|
||||
match wrapped.unwrap_err() {
|
||||
ApiError::Request(report) => {
|
||||
assert!(report.to_string().contains("request error context"));
|
||||
}
|
||||
_ => panic!("Expected Request error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_auth_error() {
|
||||
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"not found",
|
||||
));
|
||||
let wrapped = result.wrap_auth_err("auth error context");
|
||||
|
||||
assert!(wrapped.is_err());
|
||||
match wrapped.unwrap_err() {
|
||||
ApiError::Auth(report) => {
|
||||
assert!(report.to_string().contains("auth error context"));
|
||||
}
|
||||
_ => panic!("Expected Auth error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_trait_with_closure() {
|
||||
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"not found",
|
||||
));
|
||||
let wrapped =
|
||||
result.wrap_err_with(|| format!("context with {}", "dynamic"));
|
||||
|
||||
assert!(wrapped.is_err());
|
||||
assert!(
|
||||
wrapped
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("context with dynamic")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
43
packages/muralpay/Cargo.toml
Normal file
43
packages/muralpay/Cargo.toml
Normal 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
|
||||
5
packages/muralpay/README.md
Normal file
5
packages/muralpay/README.md
Normal 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/)
|
||||
321
packages/muralpay/examples/muralpay.rs
Normal file
321
packages/muralpay/examples/muralpay.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
236
packages/muralpay/src/account.rs
Normal file
236
packages/muralpay/src/account.rs
Normal 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 },
|
||||
}
|
||||
169
packages/muralpay/src/counterparty.rs
Normal file
169
packages/muralpay/src/counterparty.rs
Normal 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(¶ms.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>,
|
||||
},
|
||||
}
|
||||
117
packages/muralpay/src/error.rs
Normal file
117
packages/muralpay/src/error.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
mod account;
|
||||
mod counterparty;
|
||||
mod error;
|
||||
mod organization;
|
||||
mod payout;
|
||||
mod payout_method;
|
||||
mod serde_iso3166;
|
||||
mod util;
|
||||
|
||||
pub use {account::*, error::*, organization::*, payout::*};
|
||||
pub use {
|
||||
account::*, counterparty::*, error::*, organization::*, payout::*,
|
||||
payout_method::*,
|
||||
};
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::Deref;
|
||||
use std::{ops::Deref, str::FromStr};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const API_URL: &str = "https://api.muralpay.com";
|
||||
pub const SANDBOX_API_URL: &str = "https://api-staging.muralpay.com";
|
||||
|
||||
/// Default token symbol for [`TokenAmount::token_symbol`] values.
|
||||
pub const USDC: &str = "USDC";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MuralPay {
|
||||
pub http: reqwest::Client,
|
||||
@@ -41,6 +50,7 @@ impl MuralPay {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Blockchain {
|
||||
Ethereum,
|
||||
@@ -49,7 +59,10 @@ pub enum Blockchain {
|
||||
Celo,
|
||||
}
|
||||
|
||||
crate::util::display_as_serialize!(Blockchain);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "SCREAMING-KEBAB-CASE")]
|
||||
pub enum CurrencyCode {
|
||||
Usd,
|
||||
@@ -65,7 +78,20 @@ pub enum CurrencyCode {
|
||||
Zar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
crate::util::display_as_serialize!(CurrencyCode);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "SCREAMING-KEBAB-CASE")]
|
||||
pub enum FiatAccountType {
|
||||
Checking,
|
||||
Savings,
|
||||
}
|
||||
|
||||
crate::util::display_as_serialize!(FiatAccountType);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::EnumIter)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FiatAndRailCode {
|
||||
Usd,
|
||||
@@ -84,7 +110,18 @@ pub enum FiatAndRailCode {
|
||||
UsdPanama,
|
||||
}
|
||||
|
||||
crate::util::display_as_serialize!(FiatAndRailCode);
|
||||
|
||||
impl FromStr for FiatAndRailCode {
|
||||
type Err = serde_json::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WalletDetails {
|
||||
pub blockchain: Blockchain,
|
||||
@@ -92,15 +129,19 @@ pub struct WalletDetails {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenAmount {
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
pub token_amount: Decimal,
|
||||
pub token_symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FiatAmount {
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
pub fiat_amount: Decimal,
|
||||
pub fiat_currency_code: CurrencyCode,
|
||||
}
|
||||
@@ -126,6 +167,7 @@ impl<Id: Deref<Target = Uuid> + Clone> SearchParams<Id> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResponse<Id, T> {
|
||||
pub total: u64,
|
||||
|
||||
277
packages/muralpay/src/organization.rs
Normal file
277
packages/muralpay/src/organization.rs
Normal 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,
|
||||
}
|
||||
825
packages/muralpay/src/payout.rs
Normal file
825
packages/muralpay/src/payout.rs
Normal 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(¶ms.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>,
|
||||
}
|
||||
439
packages/muralpay/src/payout_method.rs
Normal file
439
packages/muralpay/src/payout_method.rs
Normal 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(¶ms.map(|p| p.to_query()).unwrap_or_default())
|
||||
.send_mural()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_payout_method(
|
||||
&self,
|
||||
counterparty_id: CounterpartyId,
|
||||
payout_method_id: PayoutMethodId,
|
||||
) -> Result<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,
|
||||
}
|
||||
24
packages/muralpay/src/serde_iso3166.rs
Normal file
24
packages/muralpay/src/serde_iso3166.rs
Normal 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")
|
||||
})
|
||||
})
|
||||
}
|
||||
100
packages/muralpay/src/util.rs
Normal file
100
packages/muralpay/src/util.rs
Normal 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;
|
||||
Reference in New Issue
Block a user