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