diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59d6ade --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: Format, lint, test, docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Test + run: cargo test --all-features + - name: Docs + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..bfab503 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,24 @@ +name: release-plz + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - name: Run release-plz + uses: MarcoIeni/release-plz-action@v0.5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68d43a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +**/*.rs.bk +.DS_Store +.claude/ +PLAN.md +AGENTS.md +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..378090d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2261 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blindpay" +version = "0.2.0" +dependencies = [ + "http 1.4.2", + "reqwest", + "serde", + "serde_json", + "svix", + "thiserror 1.0.69", + "tokio", + "wiremock", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.2", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[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", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "js_option" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68421373957a1593a767013698dbf206e2b221eefe97a44d98d18672ff38423c" +dependencies = [ + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "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", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svix" +version = "1.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4822dfd64810220922a5231dd6bce0f9e8fd3dcae55d3d83c348de20791daae3" +dependencies = [ + "base64 0.13.1", + "hmac-sha256", + "http 0.2.12", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "itertools", + "js_option", + "nix", + "percent-encoding", + "rand", + "rustls", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "tower-service", + "tracing", + "url", + "uuid", + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.2", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..296d4bd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "blindpay" +version = "0.2.0" +edition = "2024" +description = "Official Rust SDK for the BlindPay payments API." +license = "MIT" +repository = "https://github.com/blindpaylabs/blindpay-rust" +homepage = "https://www.blindpay.com" +documentation = "https://docs.rs/blindpay" +readme = "README.md" +keywords = ["blindpay", "payments", "stablecoin", "api", "sdk"] +categories = ["api-bindings", "finance"] + +[features] +default = ["rustls-tls"] +rustls-tls = ["reqwest/rustls-tls"] +native-tls = ["reqwest/native-tls"] + +[dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +svix = "1" +http = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +wiremock = "0.6" + +[package.metadata.docs.rs] +all-features = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..948b9d3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Blind Pay, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0d9dbd3..721d4ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,119 @@ -# blindpay-rust -BlindPay's Rust SDK. +# BlindPay Rust SDK + +The official Rust SDK for [BlindPay](https://blindpay.com) — Stablecoin API for global payments. + +## Installation + +```bash +cargo add blindpay +``` + +Or add it to your `Cargo.toml`: + +```toml +[dependencies] +blindpay = "0.1" +``` + +The SDK is async and runtime-agnostic; the examples below use [Tokio](https://tokio.rs): + +```toml +[dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +## Authentication + +To get started, you will need both your API key and your instance id. You can obtain them from the BlindPay dashboard at [https://app.blindpay.com/instances/{instanceId}/api-keys](https://app.blindpay.com/instances/{instanceId}/api-keys). + +```rust,no_run +use blindpay::BlindPay; + +let blindpay = BlindPay::new("your-api-key-here", "your-instance-id-here")?; +# let _ = blindpay; +# Ok::<(), blindpay::Error>(()) +``` + +> **Note** +> All API calls use the provided API key and instance id. + +## Quick Start + +### Check for available rails + +```rust,no_run +use blindpay::BlindPay; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let blindpay = BlindPay::new("your-api-key-here", "your-instance-id-here")?; + + let rails = blindpay.available.get_rails().await?; + for rail in &rails { + println!("{} ({}) in {}", rail.label, rail.value, rail.country); + } + + Ok(()) +} +``` + +## Response format + +Every method returns a [`Result`](https://doc.rust-lang.org/std/result/enum.Result.html), so you handle success and failure with ordinary Rust control flow — `?`, `match`, or the `Result` combinators. + +- **Success** resolves to the typed response (for example `Vec` or `Customer`). +- **Failure** is a `blindpay::Error`. API responses with a non-2xx status are `Error::Api`, which carries the HTTP `status`, the API `message`, and the raw response `body`. Other variants cover transport (`Error::Http`), response decoding (`Error::Decode`), and client configuration (`Error::Config`). + +## Error handling + +Always handle the `Result`. Match on the error to react to specific cases: + +```rust,no_run +use blindpay::{BlindPay, Error}; + +#[tokio::main] +async fn main() { + let blindpay = BlindPay::new("your-api-key-here", "your-instance-id-here").unwrap(); + + match blindpay.available.get_rails().await { + Ok(rails) => println!("Success: {} rails", rails.len()), + Err(Error::Api(err)) => eprintln!("API error {}: {}", err.status, err.message), + Err(err) => eprintln!("Error: {err}"), + } +} +``` + +## Configuration + +Use the builder for a custom timeout, base URL, or a preconfigured `reqwest::Client`: + +```rust,no_run +use std::time::Duration; +use blindpay::BlindPay; + +let blindpay = BlindPay::builder("your-api-key-here", "your-instance-id-here") + .timeout(Duration::from_secs(10)) + .build()?; +# let _ = blindpay; +# Ok::<(), blindpay::Error>(()) +``` + +### Cargo features + +- `rustls-tls` *(default)* — TLS via [rustls](https://github.com/rustls/rustls). +- `native-tls` — TLS via the platform's native library. + +For detailed API documentation, visit: +- [BlindPay API documentation](https://blindpay.com/docs/getting-started/overview) +- [API Reference](https://api.blindpay.com/reference) + +## Support + +- 📧 Email: [alves@blindpay.com](mailto:alves@blindpay.com) +- 🐛 Issues: [GitHub Issues](https://github.com/blindpaylabs/blindpay-rust/issues) + +## License + +This project is licensed under the MIT License — see the [LICENSE](https://github.com/blindpaylabs/blindpay-rust/blob/main/LICENSE) file for details. + +Made with ❤️ by the [BlindPay](https://blindpay.com) team diff --git a/examples/get_rails.rs b/examples/get_rails.rs new file mode 100644 index 0000000..eb7abfe --- /dev/null +++ b/examples/get_rails.rs @@ -0,0 +1,25 @@ +//! Fetches and prints the available payment rails. +//! +//! Run with: +//! +//! ```sh +//! BLINDPAY_API_KEY=... BLINDPAY_INSTANCE_ID=... cargo run --example get_rails +//! ``` + +use blindpay::BlindPay; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let api_key = std::env::var("BLINDPAY_API_KEY")?; + let instance_id = std::env::var("BLINDPAY_INSTANCE_ID")?; + + let client = BlindPay::new(api_key, instance_id)?; + + let rails = client.available.get_rails().await?; + println!("Found {} payment rails:", rails.len()); + for rail in rails { + println!("- {} ({}) in {}", rail.label, rail.value, rail.country); + } + + Ok(()) +} diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..3767229 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,2 @@ +[[package]] +name = "blindpay" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..39886fd --- /dev/null +++ b/src/client.rs @@ -0,0 +1,253 @@ +//! The BlindPay API client and its builder. + +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Url; + +use crate::error::{Error, Result}; +use crate::resources::available::Available; +use crate::resources::customers::Customers; +use crate::resources::fees::Fees; +use crate::resources::instances::Instances; +use crate::resources::partner_fees::PartnerFees; +use crate::resources::payins::Payins; +use crate::resources::payouts::Payouts; +use crate::resources::quotes::Quotes; +use crate::resources::transfers::Transfers; +use crate::resources::upload::Upload; +use crate::resources::virtual_accounts::VirtualAccounts; +use crate::resources::wallets::Wallets; + +/// The default BlindPay API base URL (`https://api.blindpay.com/v1`). +pub const DEFAULT_BASE_URL: &str = "https://api.blindpay.com/v1"; + +/// The default per-request timeout (30 seconds). +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Shared, reference-counted client state. +pub(crate) struct Inner { + pub(crate) http: reqwest::Client, + pub(crate) base_url: String, + pub(crate) api_key: String, + pub(crate) instance_id: String, + pub(crate) user_agent: String, +} + +/// A client for the BlindPay API. +/// +/// `BlindPay` is cheap to clone — its internal state is reference-counted, so +/// clones share the same connection pool and configuration. +/// +/// # Example +/// +/// ```no_run +/// # async fn run() -> blindpay::Result<()> { +/// let client = blindpay::BlindPay::new("your-api-key", "your-instance-id")?; +/// let rails = client.available.get_rails().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct BlindPay { + inner: Arc, + /// The `available` resource: payment rails, bank-detail field definitions, + /// NAICS codes, and SWIFT/BIC lookups. + pub available: Available, + /// The `instances` resource: instance settings, ToS, members, and webhook + /// endpoints. + pub instances: Instances, + /// The `partner_fees` resource: partner fee schedules for the instance. + pub partner_fees: PartnerFees, + /// The `fees` resource: billing fees for the instance. + pub fees: Fees, + /// The `customers` resource: customer KYC/KYB and bank accounts. + pub customers: Customers, + /// The `wallets` resource: blockchain, custodial, and offramp wallets. + pub wallets: Wallets, + /// The `virtual_accounts` resource: customer virtual accounts. + pub virtual_accounts: VirtualAccounts, + /// The `quotes` resource: payout quotes and FX rates. + pub quotes: Quotes, + /// The `payins` resource: pay-ins and their quotes. + pub payins: Payins, + /// The `payouts` resource: payouts and their tracking. + pub payouts: Payouts, + /// The `transfers` resource: transfers and their quotes. + pub transfers: Transfers, + /// The `upload` resource: file uploads. + pub upload: Upload, +} + +impl BlindPay { + /// Verifies a Svix webhook signature against the raw request body. + /// + /// Delegates to [`crate::verify_webhook_signature`]. See that function for + /// header/secret/payload details. + /// + /// # Errors + /// + /// Returns [`crate::webhooks::WebhookVerificationError`] if the signature is + /// invalid or the inputs are malformed. + #[allow(clippy::unused_self)] // inherent method for parity with other SDKs (PLAN Tier 2) + pub fn verify_webhook_signature( + &self, + secret: &str, + id: &str, + timestamp: &str, + payload: &[u8], + signature: &str, + ) -> std::result::Result<(), crate::webhooks::WebhookVerificationError> { + crate::verify_webhook_signature(secret, id, timestamp, payload, signature) + } +} + +impl BlindPay { + /// Creates a client with the given API key and instance ID, using the + /// default base URL and timeout. + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `api_key` or `instance_id` is empty. + pub fn new(api_key: impl Into, instance_id: impl Into) -> Result { + Self::builder(api_key, instance_id).build() + } + + /// Starts building a customized client (custom base URL, timeout, or + /// [`reqwest::Client`]). + pub fn builder(api_key: impl Into, instance_id: impl Into) -> BlindPayBuilder { + BlindPayBuilder::new(api_key.into(), instance_id.into()) + } + + /// Returns the instance ID this client was configured with. + pub fn instance_id(&self) -> &str { + &self.inner.instance_id + } +} + +impl std::fmt::Debug for BlindPay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BlindPay") + .field("base_url", &self.inner.base_url) + .field("instance_id", &self.inner.instance_id) + .field("api_key", &"") + .finish() + } +} + +/// A builder for [`BlindPay`], created via [`BlindPay::builder`]. +pub struct BlindPayBuilder { + api_key: String, + instance_id: String, + base_url: String, + timeout: Duration, + http_client: Option, +} + +impl BlindPayBuilder { + fn new(api_key: String, instance_id: String) -> Self { + Self { + api_key, + instance_id, + base_url: DEFAULT_BASE_URL.to_string(), + timeout: DEFAULT_TIMEOUT, + http_client: None, + } + } + + /// Overrides the API base URL. Defaults to [`DEFAULT_BASE_URL`]. + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Overrides the per-request timeout. Defaults to [`DEFAULT_TIMEOUT`]. + /// + /// Ignored when a custom client is supplied via [`Self::http_client`]. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Supplies a fully configured [`reqwest::Client`] (for proxies, custom TLS, + /// connection pooling, etc.). When set, [`Self::timeout`] is ignored — + /// configure the timeout on your own client. + pub fn http_client(mut self, http_client: reqwest::Client) -> Self { + self.http_client = Some(http_client); + self + } + + /// Builds the [`BlindPay`] client. + /// + /// # Errors + /// + /// Returns [`Error::Config`] if the API key or instance ID is empty, or if + /// the base URL cannot be parsed. + pub fn build(self) -> Result { + if self.api_key.trim().is_empty() { + return Err(Error::Config( + "api key not provided, get your api key on the blindpay dashboard".to_string(), + )); + } + if self.instance_id.trim().is_empty() { + return Err(Error::Config( + "instance id not provided, get your instance id on the blindpay dashboard" + .to_string(), + )); + } + + // Trim a trailing slash so request paths (which start with `/`) join + // cleanly, and validate that the result is a well-formed URL. + let base_url = { + let trimmed = self.base_url.trim_end_matches('/'); + Url::parse(trimmed).map_err(|e| Error::Config(format!("invalid base url: {e}")))?; + trimmed.to_string() + }; + + let http = match self.http_client { + Some(client) => client, + None => reqwest::Client::builder() + .timeout(self.timeout) + .build() + .map_err(Error::Http)?, + }; + + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + let inner = Arc::new(Inner { + http, + base_url, + api_key: self.api_key, + instance_id: self.instance_id, + user_agent, + }); + let available = Available::new(Arc::clone(&inner)); + let instances = Instances::new(Arc::clone(&inner)); + let partner_fees = PartnerFees::new(Arc::clone(&inner)); + let fees = Fees::new(Arc::clone(&inner)); + let customers = Customers::new(Arc::clone(&inner)); + let wallets = Wallets::new(Arc::clone(&inner)); + let virtual_accounts = VirtualAccounts::new(Arc::clone(&inner)); + let quotes = Quotes::new(Arc::clone(&inner)); + let payins = Payins::new(Arc::clone(&inner)); + let payouts = Payouts::new(Arc::clone(&inner)); + let transfers = Transfers::new(Arc::clone(&inner)); + let upload = Upload::new(Arc::clone(&inner)); + + Ok(BlindPay { + inner, + available, + instances, + partner_fees, + fees, + customers, + wallets, + virtual_accounts, + quotes, + payins, + payouts, + transfers, + upload, + }) + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs new file mode 100644 index 0000000..0cd540c --- /dev/null +++ b/src/common/mod.rs @@ -0,0 +1,109 @@ +//! Types shared across multiple BlindPay resources. +//! +//! Everything here is re-exported from the crate root, so e.g. [`crate::Network`] +//! and [`crate::common::Network`] name the same type. + +mod pagination; +mod scalars; + +pub use pagination::{Limit, ListResponse, Offset, Pagination, PaginationParams}; +pub use scalars::{ + AccountClass, AccountType, Country, Currency, CurrencyType, KycStatus, Network, PaymentMethod, + ProviderName, Rail, Token, TrackingStatus, TransactionStatus, TransfersType, WebhookEvent, +}; + +use serde::Deserialize; + +/// Defines an *open* string enum: a fixed set of known variants plus an +/// `Unknown(String)` catch-all, so a value this SDK version doesn't recognize +/// deserializes into `Unknown` instead of failing decoding. +/// +/// Expands to the same shape as a hand-written reference impl — `as_str`, +/// `From<&str>`, `AsRef`, `Display`, `Serialize`, and `Deserialize` — with +/// wire values matched verbatim (so casing, which varies across the API, is +/// preserved exactly). This is the single canonical pattern for categorical +/// fields. +macro_rules! open_enum { + ( + $(#[$enum_meta:meta])* + $vis:vis enum $name:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident => $wire:literal + ),+ $(,)? + } + ) => { + $(#[$enum_meta])* + #[derive(Debug, Clone, PartialEq, Eq)] + #[non_exhaustive] + $vis enum $name { + $( + $(#[$variant_meta])* + $variant, + )+ + /// A value not recognized by this version of the SDK. + Unknown(String), + } + + impl $name { + /// Returns the wire-format string for this value (as sent to and + /// received from the API). + pub fn as_str(&self) -> &str { + match self { + $( $name::$variant => $wire, )+ + $name::Unknown(value) => value, + } + } + } + + impl ::core::convert::From<&str> for $name { + fn from(value: &str) -> Self { + match value { + $( $wire => $name::$variant, )+ + other => $name::Unknown(other.to_string()), + } + } + } + + impl ::core::convert::AsRef for $name { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl ::core::fmt::Display for $name { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + f.write_str(self.as_str()) + } + } + + impl ::serde::Serialize for $name { + fn serialize(&self, serializer: S) -> ::core::result::Result + where + S: ::serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> ::serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: ::serde::Deserializer<'de>, + { + let value = ::std::string::String::deserialize(deserializer)?; + Ok($name::from(value.as_str())) + } + } + }; +} +pub(crate) use open_enum; + +/// A generic success response (`{ "success": true }`), returned by many mutating +/// endpoints (most `DELETE`s and some `PUT`s). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Success { + /// Whether the operation succeeded. + pub success: bool, +} diff --git a/src/common/pagination.rs b/src/common/pagination.rs new file mode 100644 index 0000000..2f6e3e5 --- /dev/null +++ b/src/common/pagination.rs @@ -0,0 +1,214 @@ +//! Pagination query parameters and the list-response envelope. + +use serde::{Deserialize, Serialize}; + +/// The maximum number of items a list request returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Limit { + /// Up to 10 items. + #[serde(rename = "10")] + Ten, + /// Up to 50 items. + #[serde(rename = "50")] + Fifty, + /// Up to 100 items. + #[serde(rename = "100")] + OneHundred, + /// Up to 200 items. + #[serde(rename = "200")] + TwoHundred, + /// Up to 500 items. + #[serde(rename = "500")] + FiveHundred, + /// Up to 1000 items. + #[serde(rename = "1000")] + OneThousand, +} + +/// The number of items a list request skips. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Offset { + /// Skip none. + #[serde(rename = "0")] + Zero, + /// Skip 10. + #[serde(rename = "10")] + Ten, + /// Skip 50. + #[serde(rename = "50")] + Fifty, + /// Skip 100. + #[serde(rename = "100")] + OneHundred, + /// Skip 200. + #[serde(rename = "200")] + TwoHundred, + /// Skip 500. + #[serde(rename = "500")] + FiveHundred, + /// Skip 1000. + #[serde(rename = "1000")] + OneThousand, +} + +/// Common pagination query parameters accepted by list endpoints. +/// +/// Build with [`PaginationParams::new`] and the chainable setters; only the +/// fields you set are sent on the wire. +/// +/// ``` +/// use blindpay::{Limit, PaginationParams}; +/// let params = PaginationParams::new() +/// .limit(Limit::Fifty) +/// .starting_after("cus_123"); +/// ``` +#[derive(Debug, Clone, Default, Serialize)] +#[non_exhaustive] +pub struct PaginationParams { + /// Maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Number of items to skip. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Cursor: return items after this object ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_after: Option, + /// Cursor: return items before this object ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_before: Option, +} + +impl PaginationParams { + /// Creates an empty set of pagination parameters. + pub fn new() -> Self { + Self::default() + } + + /// Sets the maximum number of items to return. + pub fn limit(mut self, limit: Limit) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the number of items to skip. + pub fn offset(mut self, offset: Offset) -> Self { + self.offset = Some(offset); + self + } + + /// Sets the `starting_after` cursor (an object ID). + pub fn starting_after(mut self, cursor: impl Into) -> Self { + self.starting_after = Some(cursor.into()); + self + } + + /// Sets the `ending_before` cursor (an object ID). + pub fn ending_before(mut self, cursor: impl Into) -> Self { + self.ending_before = Some(cursor.into()); + self + } +} + +/// Pagination metadata included in a paginated list response. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Pagination { + /// Whether more items are available after this page. + pub has_more: bool, + /// Object ID of the first item in the next page, if any. + #[serde(default)] + pub next_page: Option, + /// Object ID of the first item in the previous page, if any. + #[serde(default)] + pub prev_page: Option, +} + +/// A list response. +/// +/// The API returns either a paginated envelope +/// (`{ "data": [...], "pagination": {...} }`) or a bare array (`[...]`, when no +/// pagination parameters were sent). Use [`data`](ListResponse::data) / +/// [`into_data`](ListResponse::into_data) to read the items uniformly and +/// [`pagination`](ListResponse::pagination) for the metadata when present. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum ListResponse { + /// The paginated envelope form. + Paginated { + /// The items in this page. + data: Vec, + /// Pagination metadata. + pagination: Pagination, + }, + /// The bare-array form (no pagination metadata). + Bare(Vec), +} + +impl ListResponse { + /// Returns the items as a slice, regardless of the response form. + pub fn data(&self) -> &[T] { + match self { + ListResponse::Paginated { data, .. } => data, + ListResponse::Bare(data) => data, + } + } + + /// Consumes the response, returning the owned items. + pub fn into_data(self) -> Vec { + match self { + ListResponse::Paginated { data, .. } => data, + ListResponse::Bare(data) => data, + } + } + + /// Returns the pagination metadata, if the response was paginated. + pub fn pagination(&self) -> Option<&Pagination> { + match self { + ListResponse::Paginated { pagination, .. } => Some(pagination), + ListResponse::Bare(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limit_and_offset_serialize_to_strings() { + assert_eq!( + serde_json::to_string(&Limit::OneThousand).unwrap(), + "\"1000\"" + ); + assert_eq!(serde_json::to_string(&Offset::Zero).unwrap(), "\"0\""); + } + + #[test] + fn pagination_params_skip_unset_fields() { + let params = PaginationParams::new() + .limit(Limit::Fifty) + .starting_after("cus_123"); + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!( + json, + serde_json::json!({ "limit": "50", "starting_after": "cus_123" }) + ); + } + + #[test] + fn list_response_handles_both_shapes() { + let bare: ListResponse = serde_json::from_str(r#"["a","b"]"#).unwrap(); + assert_eq!(bare.data(), ["a".to_string(), "b".to_string()]); + assert!(bare.pagination().is_none()); + + let paged: ListResponse = serde_json::from_str( + r#"{"data":["a"],"pagination":{"has_more":true,"next_page":"x","prev_page":null}}"#, + ) + .unwrap(); + assert_eq!(paged.data(), ["a".to_string()]); + assert!(paged.pagination().unwrap().has_more); + assert_eq!(paged.pagination().unwrap().next_page.as_deref(), Some("x")); + assert!(paged.pagination().unwrap().prev_page.is_none()); + } +} diff --git a/src/common/scalars.rs b/src/common/scalars.rs new file mode 100644 index 0000000..8df79b2 --- /dev/null +++ b/src/common/scalars.rs @@ -0,0 +1,484 @@ +//! Shared scalar enums and newtypes used across resources. +//! +//! The enums are *open* (see [`open_enum`](crate::common::open_enum)): values +//! the SDK doesn't recognize decode into `Unknown` rather than failing. + +use serde::{Deserialize, Serialize}; + +use crate::common::open_enum; + +open_enum! { + /// A blockchain network supported by BlindPay. + pub enum Network { + /// Base mainnet. + Base => "base", + /// Ethereum Sepolia testnet. + Sepolia => "sepolia", + /// Arbitrum Sepolia testnet. + ArbitrumSepolia => "arbitrum_sepolia", + /// Base Sepolia testnet. + BaseSepolia => "base_sepolia", + /// Arbitrum One mainnet. + Arbitrum => "arbitrum", + /// Polygon mainnet. + Polygon => "polygon", + /// Polygon Amoy testnet. + PolygonAmoy => "polygon_amoy", + /// Ethereum mainnet. + Ethereum => "ethereum", + /// Stellar mainnet. + Stellar => "stellar", + /// Stellar testnet. + StellarTestnet => "stellar_testnet", + /// Tron mainnet. + Tron => "tron", + /// Solana mainnet. + Solana => "solana", + /// Solana devnet. + SolanaDevnet => "solana_devnet", + } +} + +open_enum! { + /// A stablecoin token. Wire values are uppercase. + pub enum Token { + /// USD Coin. + Usdc => "USDC", + /// Tether. + Usdt => "USDT", + /// BlindPay sandbox stablecoin. + Usdb => "USDB", + } +} + +open_enum! { + /// A fiat currency. Wire values are uppercase ISO 4217 codes. + pub enum Currency { + /// Brazilian Real. + Brl => "BRL", + /// US Dollar. + Usd => "USD", + /// Mexican Peso. + Mxn => "MXN", + /// Colombian Peso. + Cop => "COP", + /// Argentine Peso. + Ars => "ARS", + /// Euro. + Eur => "EUR", + } +} + +open_enum! { + /// A payin payment method. + /// + /// Distinct from [`Rail`] (used for bank accounts and payouts): payins use + /// `spei`/`transfers`/`pse` where rails use `spei_bitso`/`transfers_bitso`/ + /// `ach_cop_bitso`. + pub enum PaymentMethod { + /// ACH (US). + Ach => "ach", + /// Domestic wire (US). + Wire => "wire", + /// PIX (Brazil). + Pix => "pix", + /// TED (Brazil). + Ted => "ted", + /// SPEI (Mexico). + Spei => "spei", + /// Transfers 3.0 (Argentina). + Transfers => "transfers", + /// PSE (Colombia). + Pse => "pse", + /// International SWIFT. + InternationalSwift => "international_swift", + /// Real-Time Payments (US). + Rtp => "rtp", + } +} + +open_enum! { + /// The payment rail of a bank account or payout. + /// + /// Shared across the `available`, `bank-accounts`, `payouts`, `quotes`, and + /// `fees` resources. + pub enum Rail { + /// Domestic wire transfer (US). + Wire => "wire", + /// ACH transfer (US). + Ach => "ach", + /// PIX (Brazil). + Pix => "pix", + /// PIX Safe (Brazil). + PixSafe => "pix_safe", + /// TED (Brazil). + Ted => "ted", + /// SPEI via Bitso (Mexico). + SpeiBitso => "spei_bitso", + /// Transfers 3.0 via Bitso (Argentina). + TransfersBitso => "transfers_bitso", + /// ACH Colombia via Bitso (Colombia). + AchCopBitso => "ach_cop_bitso", + /// International SWIFT transfer. + InternationalSwift => "international_swift", + /// Real-Time Payments (US). + Rtp => "rtp", + /// SEPA (European Union). + Sepa => "sepa", + } +} + +open_enum! { + /// The KYC/KYB verification status of a customer. + pub enum KycStatus { + /// Verification in progress. + Verifying => "verifying", + /// Approved. + Approved => "approved", + /// Rejected. + Rejected => "rejected", + /// Deprecated / superseded. + Deprecated => "deprecated", + /// Pending manual review. + PendingReview => "pending_review", + /// Awaiting contract signature. + AwaitingContract => "awaiting_contract", + /// Additional compliance information requested. + ComplianceRequest => "compliance_request", + } +} + +open_enum! { + /// The status of a payin, payout, or transfer. + /// + /// This is the superset across all three resources; a given resource uses a + /// subset (for example, transfers never report `on_hold`). Being an open + /// enum keeps any per-resource value safe. + pub enum TransactionStatus { + /// Being processed. + Processing => "processing", + /// Temporarily on hold. + OnHold => "on_hold", + /// Failed. + Failed => "failed", + /// Refunded. + Refunded => "refunded", + /// Completed. + Completed => "completed", + } +} + +open_enum! { + /// Whether an account belongs to an individual or a business. + pub enum AccountClass { + /// Individual account holder. + Individual => "individual", + /// Business account holder. + Business => "business", + } +} + +open_enum! { + /// A bank account type. + pub enum AccountType { + /// Checking account. + Checking => "checking", + /// Savings account. + Saving => "saving", + } +} + +open_enum! { + /// Which side of a quote a requested amount is denominated in. + pub enum CurrencyType { + /// The sender's currency. + Sender => "sender", + /// The receiver's currency. + Receiver => "receiver", + } +} + +open_enum! { + /// A webhook event type. Wire values are dotted camelCase (e.g. + /// `bankAccount.new`), so they're matched verbatim. + pub enum WebhookEvent { + /// A receiver (legacy customer) was created. + ReceiverNew => "receiver.new", + /// A receiver was updated. + ReceiverUpdate => "receiver.update", + /// A receiver was deleted. + ReceiverDelete => "receiver.delete", + /// A customer was created. + CustomerNew => "customer.new", + /// A customer was updated. + CustomerUpdate => "customer.update", + /// A customer was deleted. + CustomerDelete => "customer.delete", + /// A bank account was created. + BankAccountNew => "bankAccount.new", + /// A payout was created. + PayoutNew => "payout.new", + /// A payout was updated. + PayoutUpdate => "payout.update", + /// A payout completed. + PayoutComplete => "payout.complete", + /// A payout's partner fee was settled. + PayoutPartnerFee => "payout.partnerFee", + /// A blockchain wallet was created. + BlockchainWalletNew => "blockchainWallet.new", + /// A payin was created. + PayinNew => "payin.new", + /// A payin was updated. + PayinUpdate => "payin.update", + /// A payin completed. + PayinComplete => "payin.complete", + /// A payin's partner fee was settled. + PayinPartnerFee => "payin.partnerFee", + /// Terms of service were accepted. + TosAccept => "tos.accept", + /// A limit-increase request was created. + LimitIncreaseNew => "limitIncrease.new", + /// A limit-increase request was updated. + LimitIncreaseUpdate => "limitIncrease.update", + /// A virtual account was created. + VirtualAccountNew => "virtualAccount.new", + /// A virtual account completed setup. + VirtualAccountComplete => "virtualAccount.complete", + /// A transfer was created. + TransferNew => "transfer.new", + /// A transfer was updated. + TransferUpdate => "transfer.update", + /// A transfer completed. + TransferComplete => "transfer.complete", + /// A custodial wallet was created. + WalletNew => "wallet.new", + /// A custodial wallet received an inbound transfer. + WalletInbound => "wallet.inbound", + } +} + +/// An ISO 3166-1 alpha-2 country code (uppercase, e.g. `"US"`, `"BR"`). +/// +/// Modeled as a transparent newtype rather than an enum: the set is large and +/// the API occasionally returns non-ISO display codes (e.g. `eu` for SEPA), so +/// this stays strongly typed yet forward-compatible. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(transparent)] +#[non_exhaustive] +pub struct Country(String); + +impl Country { + /// Returns the country code as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for Country { + fn from(value: &str) -> Self { + Country(value.to_string()) + } +} + +impl From for Country { + fn from(value: String) -> Self { + Country(value) + } +} + +impl AsRef for Country { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Country { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl PartialEq for Country { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Country { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +open_enum! { + /// The Transfers 3.0 account identifier type (Argentina). Wire values are + /// uppercase. + /// + /// Shared by bank accounts (`transfers_bitso`) and payin transfers + /// instructions. + pub enum TransfersType { + /// CVU (virtual uniform key). + Cvu => "CVU", + /// CBU (uniform banking key). + Cbu => "CBU", + /// Alias. + Alias => "ALIAS", + } +} + +open_enum! { + /// A step in a transaction's tracking timeline (shared by payins and + /// transfers). Distinct from [`TransactionStatus`]: tracking steps include + /// `pending_review` and exclude `failed`/`refunded`. + pub enum TrackingStatus { + /// Being processed. + Processing => "processing", + /// Temporarily on hold. + OnHold => "on_hold", + /// Awaiting manual review. + PendingReview => "pending_review", + /// Completed. + Completed => "completed", + } +} + +/// The name of the payment provider that processed a payin/payout leg. +/// +/// The API defines this as a large, evolving set of vendor display names, so it +/// is modeled as a transparent newtype rather than an enum — strongly typed +/// (never a bare `String`) yet forward-compatible with new providers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +#[non_exhaustive] +pub struct ProviderName(String); + +impl ProviderName { + /// Returns the provider name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for ProviderName { + fn from(value: &str) -> Self { + ProviderName(value.to_string()) + } +} + +impl From for ProviderName { + fn from(value: String) -> Self { + ProviderName(value) + } +} + +impl AsRef for ProviderName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ProviderName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rail_parses_known_and_unknown_values() { + assert_eq!(Rail::from("pix"), Rail::Pix); + assert_eq!(Rail::Pix.as_str(), "pix"); + assert_eq!( + Rail::from("brand_new_rail"), + Rail::Unknown("brand_new_rail".to_string()) + ); + } + + #[test] + fn rail_serde_round_trips() { + assert_eq!(serde_json::to_string(&Rail::Sepa).unwrap(), "\"sepa\""); + let parsed: Rail = serde_json::from_str("\"future\"").unwrap(); + assert_eq!(parsed, Rail::Unknown("future".to_string())); + } + + #[test] + fn open_enums_round_trip_uppercase_and_snake_values() { + // Uppercase wire values (tokens, currencies) survive unchanged. + assert_eq!(serde_json::to_string(&Token::Usdc).unwrap(), "\"USDC\""); + assert_eq!( + serde_json::from_str::("\"EUR\"").unwrap(), + Currency::Eur + ); + // snake_case network/status values. + assert_eq!( + serde_json::to_string(&Network::SolanaDevnet).unwrap(), + "\"solana_devnet\"" + ); + assert_eq!( + serde_json::from_str::("\"on_hold\"").unwrap(), + TransactionStatus::OnHold + ); + assert_eq!( + serde_json::from_str::("\"compliance_request\"").unwrap(), + KycStatus::ComplianceRequest + ); + // Unknown fallthrough for an unrecognized value. + assert_eq!( + serde_json::from_str::("\"newchain\"").unwrap(), + Network::Unknown("newchain".to_string()) + ); + assert_eq!(PaymentMethod::Pse.as_str(), "pse"); + assert_eq!(AccountType::Saving.as_str(), "saving"); + assert_eq!(CurrencyType::Sender.as_str(), "sender"); + assert_eq!(AccountClass::Business.as_str(), "business"); + } + + #[test] + fn webhook_event_round_trips_dotted_camel_case() { + assert_eq!( + serde_json::to_string(&WebhookEvent::BankAccountNew).unwrap(), + "\"bankAccount.new\"" + ); + assert_eq!( + serde_json::from_str::("\"payout.partnerFee\"").unwrap(), + WebhookEvent::PayoutPartnerFee + ); + assert_eq!( + serde_json::from_str::("\"future.event\"").unwrap(), + WebhookEvent::Unknown("future.event".to_string()) + ); + } + + #[test] + fn country_is_transparent() { + let c: Country = serde_json::from_str("\"US\"").unwrap(); + assert_eq!(c.as_str(), "US"); + assert_eq!(c, "US"); + assert_eq!(serde_json::to_string(&c).unwrap(), "\"US\""); + } + + #[test] + fn shared_quote_and_tracking_types_round_trip() { + assert_eq!( + serde_json::to_string(&TransfersType::Cvu).unwrap(), + "\"CVU\"" + ); + assert_eq!( + serde_json::from_str::("\"ALIAS\"").unwrap(), + TransfersType::Alias + ); + assert_eq!( + serde_json::from_str::("\"pending_review\"").unwrap(), + TrackingStatus::PendingReview + ); + let provider: ProviderName = serde_json::from_str("\"JPMorgan Chase\"").unwrap(); + assert_eq!(provider, ProviderName::from("JPMorgan Chase")); + assert_eq!( + serde_json::to_string(&provider).unwrap(), + "\"JPMorgan Chase\"" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8383bb3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +//! Error and result types for the BlindPay SDK. + +use std::fmt; + +/// A specialized [`Result`](std::result::Result) for fallible BlindPay operations. +pub type Result = std::result::Result; + +/// Errors that can occur when using the BlindPay SDK. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The client was built with invalid configuration, or a method was called + /// with an invalid argument (for example an empty API key or SWIFT code). + #[error("invalid configuration: {0}")] + Config(String), + + /// The BlindPay API returned a non-success (non-2xx) HTTP status. + #[error(transparent)] + Api(#[from] ApiError), + + /// The response body could not be deserialized into the expected type. + #[error("failed to decode response body: {source}")] + Decode { + /// The underlying deserialization error. + #[source] + source: serde_json::Error, + /// The raw response body that failed to decode, retained for debugging. + body: String, + }, + + /// An HTTP transport error occurred (DNS, connection, TLS, timeout, ...). + #[error("http request failed: {0}")] + Http(#[from] reqwest::Error), +} + +/// A structured error returned by the BlindPay API on a non-2xx response. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ApiError { + /// The HTTP status code of the response. + pub status: u16, + /// A human-readable message parsed from the response body, or a synthesized + /// message when the body has no `message` field. + pub message: String, + /// The raw, unparsed response body. Useful for debugging schema drift. + pub raw_body: String, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "blindpay api error (status {}): {}", + self.status, self.message + ) + } +} + +impl std::error::Error for ApiError {} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..dce09cd --- /dev/null +++ b/src/http.rs @@ -0,0 +1,160 @@ +//! Internal HTTP transport shared by all resources. + +use reqwest::{Method, StatusCode}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::client::Inner; +use crate::error::{ApiError, Error, Result}; + +impl Inner { + /// Performs a `GET` request with no query parameters. + pub(crate) async fn get(&self, path: &str) -> Result { + self.request::(Method::GET, path, None, None) + .await + } + + /// Performs a `GET` request whose query string is the serialized `query`. + /// + /// Pass a `#[derive(Serialize)]` params struct (use + /// `skip_serializing_if = "Option::is_none"` for optional params). + pub(crate) async fn get_query(&self, path: &str, query: &Q) -> Result + where + T: DeserializeOwned, + Q: Serialize + ?Sized, + { + self.request::(Method::GET, path, Some(query), None) + .await + } + + /// Performs an HTTP request and deserializes the JSON response into `T`. + /// + /// `path` is appended to the configured base URL (e.g. `/available/rails`). + /// `query`, when present, is serialized to the query string; `body`, when + /// present, is serialized as a JSON request body. + pub(crate) async fn request( + &self, + method: Method, + path: &str, + query: Option<&Q>, + body: Option<&B>, + ) -> Result + where + T: DeserializeOwned, + Q: Serialize + ?Sized, + B: Serialize + ?Sized, + { + let url = format!("{}{}", self.base_url, path); + + let mut req = self + .http + .request(method, &url) + .bearer_auth(&self.api_key) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "application/json"); + + if let Some(query) = query { + req = req.query(query); + } + if let Some(body) = body { + req = req.json(body); + } + + let response = req.send().await.map_err(Error::Http)?; + let status = response.status(); + let bytes = response.bytes().await.map_err(Error::Http)?; + + if !status.is_success() { + return Err(Error::Api(parse_api_error(status, &bytes))); + } + + if status == StatusCode::NO_CONTENT || bytes.is_empty() { + // No body to decode. Try `null` first so optional/scalar return + // types resolve to `None`, then fall back to `[]` so the array + // responses used by every current endpoint resolve to an empty Vec. + return serde_json::from_slice(b"null") + .or_else(|_| serde_json::from_slice(b"[]")) + .map_err(|source| Error::Decode { + source, + body: String::new(), + }); + } + + serde_json::from_slice(&bytes).map_err(|source| Error::Decode { + source, + body: String::from_utf8_lossy(&bytes).into_owned(), + }) + } + + /// Performs a multipart `POST` request and deserializes the JSON response. + /// + /// Mirrors [`Inner::request`]'s headers and error handling, but sends a + /// `reqwest::multipart::Form` body. The `Content-Type`/boundary is set by + /// reqwest — do not set it manually. + pub(crate) async fn post_multipart( + &self, + path: &str, + query: &[(&str, &str)], + form: reqwest::multipart::Form, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let response = self + .http + .post(&url) + .bearer_auth(&self.api_key) + .header(reqwest::header::USER_AGENT, &self.user_agent) + .header(reqwest::header::ACCEPT, "application/json") + .query(query) + .multipart(form) + .send() + .await + .map_err(Error::Http)?; + let status = response.status(); + let bytes = response.bytes().await.map_err(Error::Http)?; + if !status.is_success() { + return Err(Error::Api(parse_api_error(status, &bytes))); + } + if status == StatusCode::NO_CONTENT || bytes.is_empty() { + return serde_json::from_slice(b"null") + .or_else(|_| serde_json::from_slice(b"[]")) + .map_err(|source| Error::Decode { + source, + body: String::new(), + }); + } + serde_json::from_slice(&bytes).map_err(|source| Error::Decode { + source, + body: String::from_utf8_lossy(&bytes).into_owned(), + }) + } +} + +/// Builds an [`ApiError`] from a non-2xx response, parsing the documented +/// `{ "message": ... }` body shape and falling back to a synthesized message. +fn parse_api_error(status: StatusCode, body: &[u8]) -> ApiError { + #[derive(serde::Deserialize)] + struct Body { + message: Option, + } + + let raw_body = String::from_utf8_lossy(body).into_owned(); + let message = match serde_json::from_slice::(body) { + Ok(Body { + message: Some(message), + }) => message, + _ => { + let mut message = format!("HTTP {} error", status.as_u16()); + if !raw_body.is_empty() && raw_body.len() < 1000 { + message.push_str(": "); + message.push_str(&raw_body); + } + message + } + }; + + ApiError { + status: status.as_u16(), + message, + raw_body, + } +} diff --git a/src/internal.rs b/src/internal.rs new file mode 100644 index 0000000..9026649 --- /dev/null +++ b/src/internal.rs @@ -0,0 +1,30 @@ +//! Shared crate-internal helpers. Not part of the public API. + +/// Percent-encodes a single path segment, escaping every byte outside the +/// unreserved set (`A-Z a-z 0-9 - _ . ~`). Reused by any resource that +/// interpolates a caller-supplied value into a path segment. +pub(crate) fn encode_path_segment(segment: &str) -> String { + let mut encoded = String::with_capacity(segment.len()); + for byte in segment.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_only_reserved_characters() { + // A valid SWIFT code passes through unchanged. + assert_eq!(encode_path_segment("BOFAUS3NLMA"), "BOFAUS3NLMA"); + // Reserved characters are escaped so the path stays well-formed. + assert_eq!(encode_path_segment("a/b c"), "a%2Fb%20c"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4aafbcf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,81 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![doc = include_str!("../README.md")] + +mod client; +pub mod common; +mod error; +mod http; +mod internal; +pub mod resources; +pub mod webhooks; + +pub use client::{BlindPay, BlindPayBuilder, DEFAULT_BASE_URL, DEFAULT_TIMEOUT}; +pub use common::{ + AccountClass, AccountType, Country, Currency, CurrencyType, KycStatus, Limit, ListResponse, + Network, Offset, Pagination, PaginationParams, PaymentMethod, ProviderName, Rail, Success, + Token, TrackingStatus, TransactionStatus, TransfersType, WebhookEvent, +}; +pub use error::{ApiError, Error, Result}; +pub use resources::available::{ + Available, BankDetail, BankDetailItem, BankDetailKey, NaicsCode, RailEntry, RequiredWhen, + RequiredWhenOperator, SwiftCodeBankDetail, +}; +pub use resources::customers::{ + AchCopDocument, AmlHits, BankAccount, BankAccountStatus, BankAccounts, BusinessType, + CreateAchCopBitsoInput, CreateAchInput, CreateBusinessWithStandardKybInput, + CreateCustomerResponse, CreateIndividualWithEnhancedKycInput, + CreateIndividualWithStandardKycInput, CreateInternationalSwiftInput, CreatePixInput, + CreatePixSafeInput, CreateRtpInput, CreateSepaInput, CreateSpeiBitsoInput, CreateTedInput, + CreateTransfersBitsoInput, CreateWireInput, Customer, CustomerLimit, CustomerLimits, Customers, + FraudWarning, GetCustomerLimitsResponse, IdentificationDocument, KycType, KycWarning, + LimitIncrease, LimitIncreaseStatus, LimitWindow, ListBankAccountsParams, ListCustomersParams, + Owner, OwnerRole, OwnerTaxType, ProofOfAddressDocType, RecipientRelationship, + RequestLimitIncreaseInput, RequestLimitIncreaseResponse, SpeiProtocol, SupportingDocumentType, + SwiftPaymentCode, UpdateCustomerInput, +}; +pub use resources::fees::{Fee, FeeOptions, Fees}; +pub use resources::instances::{ + CreateWebhookEndpointInput, CreateWebhookEndpointResponse, InitiateTosInput, + InitiateTosResponse, Instances, Member, PortalAccess, Tos, UpdateInstanceInput, UserRole, + WebhookEndpoint, WebhookEndpointSecret, WebhookEndpoints, +}; +pub use resources::partner_fees::{CreatePartnerFeeInput, PartnerFee, PartnerFees}; +pub use resources::payins::{ + BlindpayBankAccount, BlindpayBankDetails, BlindpayBankParty, CreatePayinInput, + CreatePayinQuoteInput, CreatePayinResponse, ListPayinsParams, PayerRules, Payin, PayinQuote, + PayinQuoteFx, PayinQuoteFxInput, PayinQuotes, PayinTrackingComplete, PayinTrackingPartnerFee, + PayinTrackingPayment, PayinTrackingTransaction, PayinTransactionStatus, Payins, + PseDocumentType, PseInstruction, SwiftReceivingBank, TedInstruction, TransfersInstruction, +}; +pub use resources::payouts::{ + AuthorizeStellarTokenInput, AuthorizeStellarTokenResponse, CreateEvmPayoutInput, + CreatePayoutResponse, CreateSolanaPayoutInput, CreateStellarPayoutInput, + EstimatedTimeOfArrival, ListPayoutsParams, Payout, PayoutCompleteStatus, PayoutDocumentsStatus, + PayoutLiquidityProviderStatus, PayoutPaymentProviderStatus, PayoutTrackingComplete, + PayoutTrackingDocuments, PayoutTrackingLiquidity, PayoutTrackingPartnerFee, + PayoutTrackingPayment, PayoutTrackingStep, PayoutTrackingTransaction, PayoutTransactionStatus, + Payouts, SubmitPayoutDocumentsInput, SubmitPayoutDocumentsResponse, TransactionDocumentType, +}; +pub use resources::quotes::{ + CreateQuoteInput, GetFxRateInput, Quote, QuoteContract, QuoteContractNetwork, QuoteFx, Quotes, +}; +pub use resources::transfers::{ + CreateTransferInput, CreateTransferQuoteInput, CreateTransferResponse, Transfer, TransferQuote, + TransferQuotes, TransferTrackingStep, TransferTrackingTransactionMonitoring, Transfers, +}; +pub use resources::upload::{Upload, UploadBucket, UploadResponse}; +pub use resources::virtual_accounts::{ + BankingPartner, CreateVirtualAccountInput, SoleProprietorDocType, UpdateVirtualAccountInput, + UsAddress, UsBankCoordinates, UsSwiftIntermediaryBank, UsVirtualAccountDetails, VirtualAccount, + VirtualAccountWallet, VirtualAccounts, +}; +pub use resources::wallets::{ + BlockchainWallet, BlockchainWalletMessage, BlockchainWallets, CreateAssetTrustlineResponse, + CreateBlockchainWalletWithAddressInput, CreateBlockchainWalletWithHashInput, + CreateOfframpWalletInput, CreateWalletInput, CustodialWallets, MintUsdbSolanaInput, + MintUsdbSolanaResponse, MintUsdbStellarInput, OfframpWallet, OfframpWallets, + PrepareSolanaDelegationInput, PrepareSolanaDelegationResponse, Wallet, WalletBalance, + WalletTokenBalance, Wallets, +}; +pub use webhooks::{WebhookVerificationError, verify_webhook_signature}; diff --git a/src/resources/available/mod.rs b/src/resources/available/mod.rs new file mode 100644 index 0000000..f261da6 --- /dev/null +++ b/src/resources/available/mod.rs @@ -0,0 +1,87 @@ +//! The `available` resource: discovery endpoints for payment rails, bank-detail +//! field definitions, NAICS industry codes, and SWIFT/BIC lookups. +//! +//! These endpoints are instance-agnostic and require only a valid API key. + +mod types; + +pub use crate::common::Rail; +pub use types::{ + BankDetail, BankDetailItem, BankDetailKey, NaicsCode, RailEntry, RequiredWhen, + RequiredWhenOperator, SwiftCodeBankDetail, +}; + +use std::sync::Arc; + +use serde::Serialize; + +use crate::client::Inner; +use crate::error::{Error, Result}; +use crate::internal::encode_path_segment; + +/// Handle for the `available` resource. +/// +/// Obtained from the `available` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Available { + client: Arc, +} + +impl Available { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists every payment rail available for the account. + /// + /// `GET /available/rails` + pub async fn get_rails(&self) -> Result> { + self.client.get("/available/rails").await + } + + /// Returns the bank-detail field definitions needed to create a bank account + /// for the given `rail`. + /// + /// `GET /available/bank-details?rail={rail}` + pub async fn get_bank_details(&self, rail: Rail) -> Result> { + #[derive(Serialize)] + struct Query<'a> { + rail: &'a str, + } + + self.client + .get_query( + "/available/bank-details", + &Query { + rail: rail.as_str(), + }, + ) + .await + } + + /// Lists the NAICS business-industry codes accepted by BlindPay. + /// + /// `GET /available/naics` + pub async fn get_naics_codes(&self) -> Result> { + self.client.get("/available/naics").await + } + + /// Looks up bank details for a SWIFT/BIC code (8 or 11 characters). + /// + /// `GET /available/swift/{swift}` + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `swift` is empty. + pub async fn get_swift_code_bank_details( + &self, + swift: impl AsRef, + ) -> Result> { + let swift = swift.as_ref().trim(); + if swift.is_empty() { + return Err(Error::Config("swift code cannot be empty".to_string())); + } + let path = format!("/available/swift/{}", encode_path_segment(swift)); + self.client.get(&path).await + } +} diff --git a/src/resources/available/types.rs b/src/resources/available/types.rs new file mode 100644 index 0000000..d6f9b7f --- /dev/null +++ b/src/resources/available/types.rs @@ -0,0 +1,257 @@ +//! Request and response types for the `available` resource. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::common::Rail; + +/// An available payment rail entry returned by `Available::get_rails`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct RailEntry { + /// Human-readable label, e.g. `"Domestic Wire"`. + pub label: String, + /// The rail identifier. + pub value: Rail, + /// ISO 3166-1 alpha-2 country code, e.g. `"US"`. Kept as a string because + /// the API may return non-ISO display codes (e.g. `eu` for SEPA). + pub country: String, +} + +/// The key identifying a bank-detail field (e.g. `account_number`, `pix_key`). +/// +/// The API defines this as a large, evolving set of values, so it is modeled as +/// a transparent newtype rather than an enumerated type — strongly typed (never +/// a bare `String`) yet forward-compatible with new keys. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +#[non_exhaustive] +pub struct BankDetailKey(String); + +impl BankDetailKey { + /// Returns the key as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for BankDetailKey { + fn from(value: &str) -> Self { + BankDetailKey(value.to_string()) + } +} + +impl From for BankDetailKey { + fn from(value: String) -> Self { + BankDetailKey(value) + } +} + +impl AsRef for BankDetailKey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for BankDetailKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl PartialEq for BankDetailKey { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for BankDetailKey { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +/// The comparison operator in a [`RequiredWhen`] rule. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RequiredWhenOperator { + /// The field's value is one of the listed values. + In, + /// The field's value equals the listed value. + Eq, + /// The field's value is none of the listed values. + NotIn, + /// The field's value does not equal the listed value. + NotEq, + /// An operator not recognized by this version of the SDK. + Unknown(String), +} + +impl RequiredWhenOperator { + /// Returns the wire-format string for this operator. + pub fn as_str(&self) -> &str { + match self { + RequiredWhenOperator::In => "in", + RequiredWhenOperator::Eq => "eq", + RequiredWhenOperator::NotIn => "notIn", + RequiredWhenOperator::NotEq => "notEq", + RequiredWhenOperator::Unknown(value) => value, + } + } +} + +impl From<&str> for RequiredWhenOperator { + fn from(value: &str) -> Self { + match value { + "in" => RequiredWhenOperator::In, + "eq" => RequiredWhenOperator::Eq, + "notIn" => RequiredWhenOperator::NotIn, + "notEq" => RequiredWhenOperator::NotEq, + other => RequiredWhenOperator::Unknown(other.to_string()), + } + } +} + +impl AsRef for RequiredWhenOperator { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for RequiredWhenOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for RequiredWhenOperator { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RequiredWhenOperator { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Ok(RequiredWhenOperator::from(value.as_str())) + } +} + +/// A selectable option within a [`BankDetail`] field (e.g. one entry of a +/// dropdown). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BankDetailItem { + /// Human-readable label for the option. + pub label: String, + /// The option's value. + pub value: String, + /// Whether the option is currently active. Absent for some options. + #[serde(default)] + pub is_active: Option, +} + +/// A conditional requirement rule attached to a [`BankDetail`] field. +/// +/// The field becomes required only when the referenced `field`'s value matches, +/// according to `operator`, one of `values`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct RequiredWhen { + /// The key of the field this rule depends on. + pub field: BankDetailKey, + /// The comparison operator. + pub operator: RequiredWhenOperator, + /// The values compared against the referenced field. + pub values: Vec, +} + +/// A bank-detail field definition for a rail, returned by +/// `Available::get_bank_details`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BankDetail { + /// Human-readable label for the field. + pub label: String, + /// Validation regular expression for the field's value (may be empty). + pub regex: String, + /// The field key, e.g. `"account_number"` or `"pix_key"`. + pub key: BankDetailKey, + /// Predefined options for the field (e.g. a dropdown). Empty when the field + /// is free-form. + #[serde(default)] + pub items: Vec, + /// Whether the field is required. May be absent or `null` in the response. + #[serde(default)] + pub required: Option, + /// A conditional requirement rule, present when the field is only required + /// in certain cases. + #[serde(default, rename = "requiredWhen")] + pub required_when: Option, +} + +/// A NAICS business-industry code, returned by `Available::get_naics_codes`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct NaicsCode { + /// Human-readable label, e.g. `"(339910) Jewelry and Silverware Manufacturing"`. + pub label: String, + /// The NAICS code value, e.g. `"339910"`. + pub value: String, +} + +/// Bank details for a SWIFT/BIC code, returned by +/// `Available::get_swift_code_bank_details`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct SwiftCodeBankDetail { + /// Internal identifier for the record. + pub id: String, + /// Bank name. + pub bank: String, + /// City. + pub city: String, + /// Branch description. + pub branch: String, + /// The SWIFT/BIC code. + pub swift_code: String, + /// A link to more information about the SWIFT code. + pub swift_code_link: String, + /// Country name. + pub country: String, + /// URL-friendly country slug. + pub country_slug: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_when_operator_round_trips() { + // `notIn`/`notEq` are camelCase on the wire — they must survive serde + // unchanged, not get snake/lower-cased. + for (variant, wire) in [ + (RequiredWhenOperator::In, "\"in\""), + (RequiredWhenOperator::Eq, "\"eq\""), + (RequiredWhenOperator::NotIn, "\"notIn\""), + (RequiredWhenOperator::NotEq, "\"notEq\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), wire); + assert_eq!( + serde_json::from_str::(wire).unwrap(), + variant + ); + } + let parsed: RequiredWhenOperator = serde_json::from_str("\"weird\"").unwrap(); + assert_eq!(parsed, RequiredWhenOperator::Unknown("weird".to_string())); + } + + #[test] + fn bank_detail_key_is_transparent() { + let key: BankDetailKey = serde_json::from_str("\"account_number\"").unwrap(); + assert_eq!(key.as_str(), "account_number"); + assert_eq!(key, "account_number"); + assert_eq!(serde_json::to_string(&key).unwrap(), "\"account_number\""); + } +} diff --git a/src/resources/customers/bank_accounts.rs b/src/resources/customers/bank_accounts.rs new file mode 100644 index 0000000..ee366fd --- /dev/null +++ b/src/resources/customers/bank_accounts.rs @@ -0,0 +1,1046 @@ +//! The `bank-accounts` sub-resource: payout destinations nested under a customer. +//! +//! Accessed via `customers.bank_accounts`. Bank-account creation is a +//! discriminated union keyed by the rail; this is exposed as one constructor per +//! rail (`create_pix`, `create_wire`, …), each injecting its `type` internally. + +use std::sync::Arc; + +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::client::Inner; +use crate::common::{AccountClass, AccountType, Country, Rail, Success, TransfersType, open_enum}; +use crate::error::Result; +use crate::internal::encode_path_segment; +use crate::resources::wallets::OfframpWallet; + +/// A SWIFT payment-purpose code (the API's `swiftCode` enum). +/// +/// The API defines this as a large, evolving set of ~2500 values, so it is +/// modeled as a transparent newtype rather than an enumerated type — strongly +/// typed (never a bare `String`) yet forward-compatible. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +#[non_exhaustive] +pub struct SwiftPaymentCode(String); + +impl SwiftPaymentCode { + /// Returns the code as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for SwiftPaymentCode { + fn from(value: &str) -> Self { + SwiftPaymentCode(value.to_string()) + } +} + +impl From for SwiftPaymentCode { + fn from(value: String) -> Self { + SwiftPaymentCode(value) + } +} + +impl AsRef for SwiftPaymentCode { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SwiftPaymentCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +open_enum! { + /// The verification status of a bank account. + pub enum BankAccountStatus { + /// Verification in progress. + Verifying => "verifying", + /// Approved. + Approved => "approved", + /// Rejected. + Rejected => "rejected", + /// Deprecated / superseded. + Deprecated => "deprecated", + } +} + +open_enum! { + /// The beneficiary's relationship to the account holder. + pub enum RecipientRelationship { + /// The beneficiary is the account holder themselves. + FirstParty => "first_party", + /// Employee. + Employee => "employee", + /// Independent contractor. + IndependentContractor => "independent_contractor", + /// Vendor or supplier. + VendorOrSupplier => "vendor_or_supplier", + /// Subsidiary or affiliate. + SubsidiaryOrAffiliate => "subsidiary_or_affiliate", + /// Merchant or partner. + MerchantOrPartner => "merchant_or_partner", + /// Customer. + Customer => "customer", + /// Landlord. + Landlord => "landlord", + /// Family member. + Family => "family", + /// Other relationship. + Other => "other", + } +} + +open_enum! { + /// The SPEI account identifier protocol (Mexico). + pub enum SpeiProtocol { + /// CLABE account number. + Clabe => "clabe", + /// Debit-card number. + Debitcard => "debitcard", + /// Phone number. + Phonenum => "phonenum", + } +} + +open_enum! { + /// The Colombian ACH beneficiary document type. Wire values are uppercase. + pub enum AchCopDocument { + /// Cédula de Ciudadanía. + Cc => "CC", + /// Cédula de Extranjería. + Ce => "CE", + /// Número de Identificación Tributaria. + Nit => "NIT", + /// Passport. + Pass => "PASS", + /// Permiso Especial de Permanencia. + Pep => "PEP", + } +} + +/// A bank account (payout destination). +/// +/// Populated fields depend on the account's [`type`](BankAccount::type_) (rail); +/// fields for other rails are absent, so all rail-specific fields are optional. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BankAccount { + /// Bank-account identifier (`ba_` prefix). + pub id: String, + /// The payment rail. + #[serde(rename = "type")] + pub type_: Rail, + /// Display name for the account. + pub name: String, + /// PIX key (PIX). + #[serde(default)] + pub pix_key: Option, + /// Beneficiary name. + #[serde(default)] + pub beneficiary_name: Option, + /// Routing number (ACH/wire/RTP). + #[serde(default)] + pub routing_number: Option, + /// Account number. + #[serde(default)] + pub account_number: Option, + /// Account type (checking/saving). + #[serde(default)] + pub account_type: Option, + /// Account class (individual/business). + #[serde(default)] + pub account_class: Option, + /// First address line. + #[serde(default)] + pub address_line_1: Option, + /// Second address line. + #[serde(default)] + pub address_line_2: Option, + /// City. + #[serde(default)] + pub city: Option, + /// State, province, or region. + #[serde(default)] + pub state_province_region: Option, + /// Country. + #[serde(default)] + pub country: Option, + /// Postal code. + #[serde(default)] + pub postal_code: Option, + /// SPEI protocol (Mexico). + #[serde(default)] + pub spei_protocol: Option, + /// SPEI institution code (Mexico). + #[serde(default)] + pub spei_institution_code: Option, + /// SPEI CLABE (Mexico). + #[serde(default)] + pub spei_clabe: Option, + /// Transfers 3.0 type (Argentina). + #[serde(default)] + pub transfers_type: Option, + /// Transfers 3.0 account (Argentina). + #[serde(default)] + pub transfers_account: Option, + /// Colombian ACH beneficiary first name. + #[serde(default)] + pub ach_cop_beneficiary_first_name: Option, + /// Colombian ACH beneficiary last name. + #[serde(default)] + pub ach_cop_beneficiary_last_name: Option, + /// Colombian ACH document ID. + #[serde(default)] + pub ach_cop_document_id: Option, + /// Colombian ACH document type. + #[serde(default)] + pub ach_cop_document_type: Option, + /// Colombian ACH beneficiary email. + #[serde(default)] + pub ach_cop_email: Option, + /// Colombian ACH bank code. + #[serde(default)] + pub ach_cop_bank_code: Option, + /// Colombian ACH bank account. + #[serde(default)] + pub ach_cop_bank_account: Option, + /// SWIFT/BIC code (international SWIFT). + #[serde(default)] + pub swift_code_bic: Option, + /// SWIFT account holder name. + #[serde(default)] + pub swift_account_holder_name: Option, + /// SWIFT account number / IBAN. + #[serde(default)] + pub swift_account_number_iban: Option, + /// SWIFT beneficiary first address line. + #[serde(default)] + pub swift_beneficiary_address_line_1: Option, + /// SWIFT beneficiary second address line. + #[serde(default)] + pub swift_beneficiary_address_line_2: Option, + /// SWIFT beneficiary country. + #[serde(default)] + pub swift_beneficiary_country: Option, + /// SWIFT beneficiary city. + #[serde(default)] + pub swift_beneficiary_city: Option, + /// SWIFT beneficiary state / province / region. + #[serde(default)] + pub swift_beneficiary_state_province_region: Option, + /// SWIFT beneficiary postal code. + #[serde(default)] + pub swift_beneficiary_postal_code: Option, + /// SWIFT bank name. + #[serde(default)] + pub swift_bank_name: Option, + /// SWIFT bank first address line. + #[serde(default)] + pub swift_bank_address_line_1: Option, + /// SWIFT bank second address line. + #[serde(default)] + pub swift_bank_address_line_2: Option, + /// SWIFT bank country. + #[serde(default)] + pub swift_bank_country: Option, + /// SWIFT bank city. + #[serde(default)] + pub swift_bank_city: Option, + /// SWIFT bank state / province / region. + #[serde(default)] + pub swift_bank_state_province_region: Option, + /// SWIFT bank postal code. + #[serde(default)] + pub swift_bank_postal_code: Option, + /// SWIFT intermediary bank SWIFT/BIC code. + #[serde(default)] + pub swift_intermediary_bank_swift_code_bic: Option, + /// SWIFT intermediary bank account number / IBAN. + #[serde(default)] + pub swift_intermediary_bank_account_number_iban: Option, + /// SWIFT intermediary bank name. + #[serde(default)] + pub swift_intermediary_bank_name: Option, + /// SWIFT intermediary bank country. + #[serde(default)] + pub swift_intermediary_bank_country: Option, + /// SEPA IBAN. + #[serde(default)] + pub sepa_iban: Option, + /// SEPA beneficiary BIC. + #[serde(default)] + pub sepa_beneficiary_bic: Option, + /// SEPA beneficiary legal name. + #[serde(default)] + pub sepa_beneficiary_legal_name: Option, + /// SEPA beneficiary first address line. + #[serde(default)] + pub sepa_beneficiary_address_line_1: Option, + /// SEPA beneficiary second address line. + #[serde(default)] + pub sepa_beneficiary_address_line_2: Option, + /// SEPA beneficiary city. + #[serde(default)] + pub sepa_beneficiary_city: Option, + /// SEPA beneficiary state / province / region. + #[serde(default)] + pub sepa_beneficiary_state_province_region: Option, + /// SEPA beneficiary postal code. + #[serde(default)] + pub sepa_beneficiary_postal_code: Option, + /// SEPA beneficiary country. + #[serde(default)] + pub sepa_beneficiary_country: Option, + /// PIX Safe bank code. + #[serde(default)] + pub pix_safe_bank_code: Option, + /// PIX Safe branch code. + #[serde(default)] + pub pix_safe_branch_code: Option, + /// PIX Safe CPF/CNPJ. + #[serde(default)] + pub pix_safe_cpf_cnpj: Option, + /// TED bank code. + #[serde(default)] + pub ted_bank_code: Option, + /// TED branch code. + #[serde(default)] + pub ted_branch_code: Option, + /// TED CPF/CNPJ. + #[serde(default)] + pub ted_cpf_cnpj: Option, + /// Tron wallet hash, when an off-ramp wallet is provisioned. + #[serde(default)] + pub tron_wallet_hash: Option, + /// Business NAICS industry code. + #[serde(default)] + pub business_industry: Option, + /// Phone number. + #[serde(default)] + pub phone_number: Option, + /// Tax identifier. + #[serde(default)] + pub tax_id: Option, + /// Date of birth. + #[serde(default)] + pub date_of_birth: Option, + /// SWIFT payment code. + #[serde(default)] + pub swift_payment_code: Option, + /// SWIFT IFSC branch code (required when the SWIFT bank country is `IN`). + #[serde(default)] + pub swift_ifsc_branch_code: Option, + /// The verification status. + #[serde(default)] + pub status: Option, + /// The beneficiary's relationship to the account holder. + #[serde(default)] + pub recipient_relationship: Option, + /// Off-ramp wallets provisioned for this account. + #[serde(default)] + pub offramp_wallets: Option>, + /// Creation timestamp. + #[serde(default)] + pub created_at: Option, +} + +/// Query parameters for listing bank accounts. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ListBankAccountsParams { + /// Filter by verification status. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Filter by rail. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_: Option, + /// Filter by name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Filter by bank-account ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub bank_account_id: Option, + /// Filter by country. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, +} + +impl ListBankAccountsParams { + /// Creates an empty set of list parameters. + pub fn new() -> Self { + Self::default() + } + + /// Filters by verification status. + pub fn status(mut self, status: BankAccountStatus) -> Self { + self.status = Some(status); + self + } + + /// Filters by rail. + pub fn rail(mut self, rail: Rail) -> Self { + self.type_ = Some(rail); + self + } + + /// Filters by name. + pub fn name(mut self, value: impl Into) -> Self { + self.name = Some(value.into()); + self + } + + /// Filters by bank-account ID. + pub fn bank_account_id(mut self, value: impl Into) -> Self { + self.bank_account_id = Some(value.into()); + self + } + + /// Filters by country. + pub fn country(mut self, country: impl Into) -> Self { + self.country = Some(country.into()); + self + } +} + +/// Input for [`BankAccounts::create_pix`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreatePixInput { + /// Display name for the account. + pub name: String, + /// PIX key. + pub pix_key: String, + /// Whether to force CPF/CNPJ validation of the PIX key. + #[serde(skip_serializing_if = "Option::is_none")] + pub force_cpf_cnpj: Option, +} + +/// Input for [`BankAccounts::create_pix_safe`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreatePixSafeInput { + /// Display name for the account. + pub name: String, + /// Beneficiary name. + pub beneficiary_name: String, + /// Account number. + pub account_number: String, + /// Account type. + pub account_type: AccountType, + /// PIX Safe bank code. + pub pix_safe_bank_code: String, + /// PIX Safe branch code. + pub pix_safe_branch_code: String, + /// PIX Safe CPF/CNPJ. + pub pix_safe_cpf_cnpj: String, +} + +/// Input for [`BankAccounts::create_ted`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateTedInput { + /// Display name for the account. + pub name: String, + /// Beneficiary name. + pub beneficiary_name: String, + /// Account number. + pub account_number: String, + /// Account type. + pub account_type: AccountType, + /// TED bank code. + pub ted_bank_code: String, + /// TED branch code. + pub ted_branch_code: String, + /// TED CPF/CNPJ. + pub ted_cpf_cnpj: String, +} + +/// Input for [`BankAccounts::create_spei_bitso`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateSpeiBitsoInput { + /// Display name for the account. + pub name: String, + /// Beneficiary name. + pub beneficiary_name: String, + /// SPEI protocol. + pub spei_protocol: SpeiProtocol, + /// SPEI CLABE / card / phone number. + pub spei_clabe: String, + /// SPEI institution code (required for `debitcard`/`phonenum`). + #[serde(skip_serializing_if = "Option::is_none")] + pub spei_institution_code: Option, +} + +/// Input for [`BankAccounts::create_transfers_bitso`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateTransfersBitsoInput { + /// Display name for the account. + pub name: String, + /// Beneficiary name. + pub beneficiary_name: String, + /// Transfers 3.0 type. + pub transfers_type: TransfersType, + /// Transfers 3.0 account. + pub transfers_account: String, +} + +/// Input for [`BankAccounts::create_ach_cop_bitso`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateAchCopBitsoInput { + /// Display name for the account. + pub name: String, + /// Account type. + pub account_type: AccountType, + /// Beneficiary first name. + pub ach_cop_beneficiary_first_name: String, + /// Beneficiary last name. + pub ach_cop_beneficiary_last_name: String, + /// Beneficiary document ID. + pub ach_cop_document_id: String, + /// Beneficiary document type. + pub ach_cop_document_type: AchCopDocument, + /// Beneficiary email. + pub ach_cop_email: String, + /// Bank code. + pub ach_cop_bank_code: String, + /// Bank account. + pub ach_cop_bank_account: String, +} + +/// Input for [`BankAccounts::create_ach`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateAchInput { + /// Display name for the account. + pub name: String, + /// Account class. + pub account_class: AccountClass, + /// Account number. + pub account_number: String, + /// Account type. + pub account_type: AccountType, + /// Beneficiary name. + pub beneficiary_name: String, + /// Routing number. + pub routing_number: String, + /// The beneficiary's relationship to the account holder. + pub recipient_relationship: RecipientRelationship, + /// First address line. + pub address_line_1: String, + /// City. + pub city: String, + /// State, province, or region. + pub state_province_region: String, + /// Country. + pub country: Country, + /// Postal code. + pub postal_code: String, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// Business NAICS industry code (required for business accounts). + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Date of birth. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +/// Input for [`BankAccounts::create_wire`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateWireInput { + /// Display name for the account. + pub name: String, + /// Account class. + pub account_class: AccountClass, + /// Account number. + pub account_number: String, + /// Beneficiary name. + pub beneficiary_name: String, + /// Routing number. + pub routing_number: String, + /// The beneficiary's relationship to the account holder. + pub recipient_relationship: RecipientRelationship, + /// First address line. + pub address_line_1: String, + /// City. + pub city: String, + /// State, province, or region. + pub state_province_region: String, + /// Country. + pub country: Country, + /// Postal code. + pub postal_code: String, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// Business NAICS industry code (required for business accounts). + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Date of birth. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +/// Input for [`BankAccounts::create_rtp`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateRtpInput { + /// Display name for the account. + pub name: String, + /// Account class. + pub account_class: AccountClass, + /// Beneficiary name. + pub beneficiary_name: String, + /// Routing number. + pub routing_number: String, + /// Account number. + pub account_number: String, + /// The beneficiary's relationship to the account holder. + pub recipient_relationship: RecipientRelationship, + /// First address line. + pub address_line_1: String, + /// City. + pub city: String, + /// State, province, or region. + pub state_province_region: String, + /// Country. + pub country: Country, + /// Postal code. + pub postal_code: String, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// Business NAICS industry code (required for business accounts). + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Date of birth. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +/// Input for [`BankAccounts::create_international_swift`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateInternationalSwiftInput { + /// Display name for the account. + pub name: String, + /// Account class. + pub account_class: AccountClass, + /// The beneficiary's relationship to the account holder. + pub recipient_relationship: RecipientRelationship, + /// SWIFT account holder name. + pub swift_account_holder_name: String, + /// SWIFT account number / IBAN. + pub swift_account_number_iban: String, + /// SWIFT/BIC code. + pub swift_code_bic: String, + /// SWIFT beneficiary first address line. + pub swift_beneficiary_address_line_1: String, + /// SWIFT beneficiary city. + pub swift_beneficiary_city: String, + /// SWIFT beneficiary country. + pub swift_beneficiary_country: Country, + /// SWIFT beneficiary postal code. + pub swift_beneficiary_postal_code: String, + /// SWIFT beneficiary state / province / region. + pub swift_beneficiary_state_province_region: String, + /// SWIFT bank name. + pub swift_bank_name: String, + /// SWIFT bank first address line. + pub swift_bank_address_line_1: String, + /// SWIFT bank city. + pub swift_bank_city: String, + /// SWIFT bank country. + pub swift_bank_country: Country, + /// SWIFT bank postal code. + pub swift_bank_postal_code: String, + /// SWIFT bank state / province / region. + pub swift_bank_state_province_region: String, + /// SWIFT beneficiary second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_beneficiary_address_line_2: Option, + /// SWIFT bank second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_bank_address_line_2: Option, + /// SWIFT intermediary bank SWIFT/BIC code. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_intermediary_bank_swift_code_bic: Option, + /// SWIFT intermediary bank account number / IBAN. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_intermediary_bank_account_number_iban: Option, + /// SWIFT intermediary bank name. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_intermediary_bank_name: Option, + /// SWIFT intermediary bank country. + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_intermediary_bank_country: Option, + /// SWIFT payment code (required for some destination countries). + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_payment_code: Option, + /// SWIFT IFSC branch code (required when the SWIFT bank country is `IN`). + #[serde(skip_serializing_if = "Option::is_none")] + pub swift_ifsc_branch_code: Option, + /// Business NAICS industry code (required for business accounts). + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Date of birth. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +/// Input for [`BankAccounts::create_sepa`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateSepaInput { + /// Display name for the account. + pub name: String, + /// Account class. + pub account_class: AccountClass, + /// SEPA IBAN. + pub sepa_iban: String, + /// SEPA beneficiary BIC. + pub sepa_beneficiary_bic: String, + /// SEPA beneficiary legal name. + pub sepa_beneficiary_legal_name: String, + /// SEPA beneficiary first address line. + pub sepa_beneficiary_address_line_1: String, + /// SEPA beneficiary city. + pub sepa_beneficiary_city: String, + /// SEPA beneficiary postal code. + pub sepa_beneficiary_postal_code: String, + /// SEPA beneficiary country. + pub sepa_beneficiary_country: Country, + /// SEPA beneficiary second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub sepa_beneficiary_address_line_2: Option, + /// SEPA beneficiary state / province / region. + #[serde(skip_serializing_if = "Option::is_none")] + pub sepa_beneficiary_state_province_region: Option, +} + +/// Handle for the `bank-accounts` sub-resource (`customers.bank_accounts`). +/// +#[derive(Clone)] +pub struct BankAccounts { + client: Arc, +} + +impl BankAccounts { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the bank accounts of a customer. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn list( + &self, + customer_id: impl AsRef, + params: &ListBankAccountsParams, + ) -> Result> { + let path = self.collection_path(customer_id); + self.client.get_query(&path, params).await + } + + /// Retrieves a single bank account by ID. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/bank-accounts/{id}` + pub async fn get( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "{}/{}", + self.collection_path(customer_id), + encode_path_segment(id.as_ref()) + ); + self.client.get(&path).await + } + + /// Deletes a bank account. + /// + /// `DELETE /instances/{instance_id}/customers/{customer_id}/bank-accounts/{id}` + pub async fn delete( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "{}/{}", + self.collection_path(customer_id), + encode_path_segment(id.as_ref()) + ); + self.client + .request(Method::DELETE, &path, None::<&()>, None::<&()>) + .await + } + + /// Creates a PIX bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_pix( + &self, + customer_id: impl AsRef, + input: &CreatePixInput, + ) -> Result { + self.create(customer_id, "pix", input).await + } + + /// Creates a PIX Safe bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_pix_safe( + &self, + customer_id: impl AsRef, + input: &CreatePixSafeInput, + ) -> Result { + self.create(customer_id, "pix_safe", input).await + } + + /// Creates a TED bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_ted( + &self, + customer_id: impl AsRef, + input: &CreateTedInput, + ) -> Result { + self.create(customer_id, "ted", input).await + } + + /// Creates a SPEI (via Bitso) bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_spei_bitso( + &self, + customer_id: impl AsRef, + input: &CreateSpeiBitsoInput, + ) -> Result { + self.create(customer_id, "spei_bitso", input).await + } + + /// Creates a Transfers 3.0 (via Bitso) bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_transfers_bitso( + &self, + customer_id: impl AsRef, + input: &CreateTransfersBitsoInput, + ) -> Result { + self.create(customer_id, "transfers_bitso", input).await + } + + /// Creates a Colombian ACH (via Bitso) bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_ach_cop_bitso( + &self, + customer_id: impl AsRef, + input: &CreateAchCopBitsoInput, + ) -> Result { + self.create(customer_id, "ach_cop_bitso", input).await + } + + /// Creates an ACH bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_ach( + &self, + customer_id: impl AsRef, + input: &CreateAchInput, + ) -> Result { + self.create(customer_id, "ach", input).await + } + + /// Creates a domestic-wire bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_wire( + &self, + customer_id: impl AsRef, + input: &CreateWireInput, + ) -> Result { + self.create(customer_id, "wire", input).await + } + + /// Creates an RTP bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_rtp( + &self, + customer_id: impl AsRef, + input: &CreateRtpInput, + ) -> Result { + self.create(customer_id, "rtp", input).await + } + + /// Creates an international SWIFT bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_international_swift( + &self, + customer_id: impl AsRef, + input: &CreateInternationalSwiftInput, + ) -> Result { + self.create(customer_id, "international_swift", input).await + } + + /// Creates a SEPA bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts` + pub async fn create_sepa( + &self, + customer_id: impl AsRef, + input: &CreateSepaInput, + ) -> Result { + self.create(customer_id, "sepa", input).await + } + + async fn create( + &self, + customer_id: impl AsRef, + rail: &'static str, + input: &T, + ) -> Result { + let body = Discriminated { type_: rail, input }; + let path = self.collection_path(customer_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(&body)) + .await + } + + fn collection_path(&self, customer_id: impl AsRef) -> String { + format!( + "/instances/{}/customers/{}/bank-accounts", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ) + } +} + +/// Wraps a per-rail create input with the `type` discriminator the API's +/// discriminated union expects, flattening the input alongside it. +#[derive(Serialize)] +struct Discriminated<'a, T: Serialize> { + #[serde(rename = "type")] + type_: &'static str, + #[serde(flatten)] + input: &'a T, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transfers_type_uses_uppercase_wire() { + assert_eq!( + serde_json::to_string(&TransfersType::Cvu).unwrap(), + "\"CVU\"" + ); + assert_eq!( + serde_json::from_str::("\"ALIAS\"").unwrap(), + TransfersType::Alias + ); + } + + #[test] + fn ach_cop_document_uses_uppercase_wire() { + assert_eq!( + serde_json::to_string(&AchCopDocument::Nit).unwrap(), + "\"NIT\"" + ); + assert_eq!( + serde_json::from_str::("\"CC\"").unwrap(), + AchCopDocument::Cc + ); + } + + #[test] + fn spei_protocol_round_trips() { + assert_eq!( + serde_json::to_string(&SpeiProtocol::Clabe).unwrap(), + "\"clabe\"" + ); + } + + #[test] + fn bank_account_status_round_trips() { + assert_eq!( + serde_json::from_str::("\"verifying\"").unwrap(), + BankAccountStatus::Verifying + ); + } + + #[test] + fn recipient_relationship_round_trips() { + assert_eq!( + serde_json::to_string(&RecipientRelationship::VendorOrSupplier).unwrap(), + "\"vendor_or_supplier\"" + ); + } + + #[test] + fn swift_payment_code_serde_round_trips() { + let code = SwiftPaymentCode::from("GOODS"); + assert_eq!(serde_json::to_string(&code).unwrap(), "\"GOODS\""); + assert_eq!( + serde_json::from_str::("\"SERVICES\"").unwrap(), + SwiftPaymentCode::from("SERVICES") + ); + assert_eq!(code.as_str(), "GOODS"); + } + + #[test] + fn discriminated_create_body_injects_type_and_flattens_input() { + let input = CreatePixInput { + name: "My PIX".to_string(), + pix_key: "11122233344".to_string(), + force_cpf_cnpj: None, + }; + let body = Discriminated { + type_: "pix", + input: &input, + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["type"], "pix"); + assert_eq!(json["name"], "My PIX"); + assert_eq!(json["pix_key"], "11122233344"); + assert!(json.get("force_cpf_cnpj").is_none()); + } + + #[test] + fn list_params_serialize_type_as_type_key() { + let params = ListBankAccountsParams::new() + .rail(Rail::Pix) + .status(BankAccountStatus::Approved); + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["type"], "pix"); + assert_eq!(json["status"], "approved"); + assert!(json.get("name").is_none()); + } +} diff --git a/src/resources/customers/mod.rs b/src/resources/customers/mod.rs new file mode 100644 index 0000000..3a05275 --- /dev/null +++ b/src/resources/customers/mod.rs @@ -0,0 +1,218 @@ +//! The `customers` resource: KYC/KYB customer records and their bank accounts. +//! +//! Customers are instance-scoped. The handle exposes the +//! [`bank_accounts`](Customers::bank_accounts) sub-resource. + +mod bank_accounts; +mod types; + +pub use bank_accounts::{ + AchCopDocument, BankAccount, BankAccountStatus, BankAccounts, CreateAchCopBitsoInput, + CreateAchInput, CreateInternationalSwiftInput, CreatePixInput, CreatePixSafeInput, + CreateRtpInput, CreateSepaInput, CreateSpeiBitsoInput, CreateTedInput, + CreateTransfersBitsoInput, CreateWireInput, ListBankAccountsParams, RecipientRelationship, + SpeiProtocol, SwiftPaymentCode, +}; +pub use types::{ + AmlHits, BusinessType, CreateBusinessWithStandardKybInput, CreateCustomerResponse, + CreateIndividualWithEnhancedKycInput, CreateIndividualWithStandardKycInput, Customer, + CustomerLimit, CustomerLimits, FraudWarning, GetCustomerLimitsResponse, IdentificationDocument, + KycType, KycWarning, LimitIncrease, LimitIncreaseStatus, LimitWindow, ListCustomersParams, + Owner, OwnerRole, OwnerTaxType, ProofOfAddressDocType, RequestLimitIncreaseInput, + RequestLimitIncreaseResponse, SupportingDocumentType, UpdateCustomerInput, +}; + +use std::sync::Arc; + +use reqwest::Method; +use serde::Serialize; + +use crate::client::Inner; +use crate::common::{ListResponse, Success}; +use crate::error::Result; +use crate::internal::encode_path_segment; + +/// Handle for the `customers` resource. +/// +/// Obtained from the `customers` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Customers { + client: Arc, + /// Bank accounts nested under a customer. + pub bank_accounts: BankAccounts, +} + +impl Customers { + pub(crate) fn new(client: Arc) -> Self { + Self { + bank_accounts: BankAccounts::new(Arc::clone(&client)), + client, + } + } + + /// Lists customers for the instance. + /// + /// `GET /instances/{instance_id}/customers` + pub async fn list(&self, params: &ListCustomersParams) -> Result> { + let path = format!("/instances/{}/customers", self.client.instance_id); + self.client.get_query(&path, params).await + } + + /// Creates an individual customer with standard KYC. + /// + /// `POST /instances/{instance_id}/customers` + pub async fn create_individual_with_standard_kyc( + &self, + input: &CreateIndividualWithStandardKycInput, + ) -> Result { + self.create(Discriminated { + account_type: "individual", + kyc_type: "standard", + input, + }) + .await + } + + /// Creates an individual customer with enhanced KYC. + /// + /// `POST /instances/{instance_id}/customers` + pub async fn create_individual_with_enhanced_kyc( + &self, + input: &CreateIndividualWithEnhancedKycInput, + ) -> Result { + self.create(Discriminated { + account_type: "individual", + kyc_type: "enhanced", + input, + }) + .await + } + + /// Creates a business customer with standard KYB. + /// + /// `POST /instances/{instance_id}/customers` + pub async fn create_business_with_standard_kyb( + &self, + input: &CreateBusinessWithStandardKybInput, + ) -> Result { + self.create(Discriminated { + account_type: "business", + kyc_type: "standard", + input, + }) + .await + } + + async fn create( + &self, + body: Discriminated<'_, T>, + ) -> Result { + let path = format!("/instances/{}/customers", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(&body)) + .await + } + + /// Retrieves a single customer by ID. + /// + /// `GET /instances/{instance_id}/customers/{id}` + pub async fn get(&self, customer_id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/customers/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Updates a customer. All body fields are optional. + /// + /// `PUT /instances/{instance_id}/customers/{id}` + pub async fn update( + &self, + customer_id: impl AsRef, + input: &UpdateCustomerInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client + .request(Method::PUT, &path, None::<&()>, Some(input)) + .await + } + + /// Deletes a customer. + /// + /// `DELETE /instances/{instance_id}/customers/{id}` + pub async fn delete(&self, customer_id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/customers/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client + .request(Method::DELETE, &path, None::<&()>, None::<&()>) + .await + } + + /// Retrieves a customer's aggregate payin/payout limits. + /// + /// `GET /instances/{instance_id}/limits/customers/{id}` + pub async fn get_limits( + &self, + customer_id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/limits/customers/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Lists a customer's limit-increase requests. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/limit-increase` + pub async fn get_limit_increase_requests( + &self, + customer_id: impl AsRef, + ) -> Result> { + let path = format!( + "/instances/{}/customers/{}/limit-increase", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Requests a limit increase for a customer. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/limit-increase` + pub async fn request_limit_increase( + &self, + customer_id: impl AsRef, + input: &RequestLimitIncreaseInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/limit-increase", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client + .request(Method::POST, &path, None::<&()>, Some(input)) + .await + } +} + +/// Wraps a per-variant create input with the `type` + `kyc_type` discriminators +/// the API's discriminated union expects, flattening the input alongside them. +#[derive(Serialize)] +struct Discriminated<'a, T: Serialize> { + #[serde(rename = "type")] + account_type: &'static str, + kyc_type: &'static str, + #[serde(flatten)] + input: &'a T, +} diff --git a/src/resources/customers/types.rs b/src/resources/customers/types.rs new file mode 100644 index 0000000..318a13c --- /dev/null +++ b/src/resources/customers/types.rs @@ -0,0 +1,1138 @@ +//! Request and response types for the `customers` resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{AccountClass, Country, KycStatus, open_enum}; + +open_enum! { + /// The KYC/KYB tier applied to a customer. + pub enum KycType { + /// Light verification. + Light => "light", + /// Standard verification. + Standard => "standard", + /// Enhanced verification. + Enhanced => "enhanced", + } +} + +open_enum! { + /// The legal structure of a business customer. + pub enum BusinessType { + /// Corporation. + Corporation => "corporation", + /// Limited liability company. + Llc => "llc", + /// Partnership. + Partnership => "partnership", + /// Sole proprietorship. + SoleProprietorship => "sole_proprietorship", + /// Trust. + Trust => "trust", + /// Non-profit organization. + NonProfit => "non_profit", + } +} + +open_enum! { + /// The type of document supporting a limit-increase request. + pub enum SupportingDocumentType { + /// Individual bank statement. + IndividualBankStatement => "individual_bank_statement", + /// Individual tax return. + IndividualTaxReturn => "individual_tax_return", + /// Individual proof of income. + IndividualProofOfIncome => "individual_proof_of_income", + /// Business bank statement. + BusinessBankStatement => "business_bank_statement", + /// Business financial statements. + BusinessFinancialStatements => "business_financial_statements", + /// Business tax return. + BusinessTaxReturn => "business_tax_return", + } +} + +open_enum! { + /// The status of a limit-increase request. + pub enum LimitIncreaseStatus { + /// Under review. + InReview => "in_review", + /// Approved. + Approved => "approved", + /// Rejected. + Rejected => "rejected", + } +} + +open_enum! { + /// The type of an identification document. + pub enum IdentificationDocument { + /// Passport. + Passport => "PASSPORT", + /// National ID card. + IdCard => "ID_CARD", + /// Driver's license. + Drivers => "DRIVERS", + } +} + +open_enum! { + /// The type of a proof-of-address document. + pub enum ProofOfAddressDocType { + /// Utility bill. + UtilityBill => "UTILITY_BILL", + /// Bank statement. + BankStatement => "BANK_STATEMENT", + /// Rental agreement. + RentalAgreement => "RENTAL_AGREEMENT", + /// Tax document. + TaxDocument => "TAX_DOCUMENT", + /// Government correspondence. + GovernmentCorrespondence => "GOVERNMENT_CORRESPONDENCE", + } +} + +open_enum! { + /// The tax-identifier type of a business owner. Mandatory when the owner's + /// country is US. + pub enum OwnerTaxType { + /// Social Security Number. + Ssn => "SSN", + /// Individual Taxpayer Identification Number. + Itin => "ITIN", + } +} + +open_enum! { + /// The role of a business owner. + pub enum OwnerRole { + /// Both a beneficial owner and a controlling person. + BeneficialControlling => "beneficial_controlling", + /// A beneficial owner. + BeneficialOwner => "beneficial_owner", + /// A controlling person. + ControllingPerson => "controlling_person", + } +} + +/// A beneficial owner of a business customer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Owner { + /// The owner's role in the business. + pub role: OwnerRole, + /// First name. + pub first_name: String, + /// Last name. + pub last_name: String, + /// Date of birth (ISO 8601). + pub date_of_birth: String, + /// Tax identifier. + pub tax_id: String, + /// Tax-identifier type (mandatory when the country is US). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tax_type: Option, + /// First address line. + pub address_line_1: String, + /// Second address line. + #[serde(default)] + pub address_line_2: Option, + /// City. + pub city: String, + /// State, province, or region. + pub state_province_region: String, + /// Country. + pub country: Country, + /// Postal code. + pub postal_code: String, + /// Country that issued the identification document. + pub id_doc_country: Country, + /// Identification document type. + pub id_doc_type: IdentificationDocument, + /// URL of the front of the identification document. + pub id_doc_front_file: String, + /// URL of the back of the identification document, if applicable. + #[serde(default)] + pub id_doc_back_file: Option, + /// Proof-of-address document type. + pub proof_of_address_doc_type: ProofOfAddressDocType, + /// URL of the proof-of-address document, if applicable. + #[serde(default)] + pub proof_of_address_doc_file: Option, + /// Ownership percentage. + #[serde(default)] + pub ownership_percentage: Option, + /// Job title. + #[serde(default)] + pub title: Option, +} + +/// A KYC/KYB warning attached to a customer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct KycWarning { + /// Warning code. + #[serde(default)] + pub code: Option, + /// Human-readable warning message. + #[serde(default)] + pub message: Option, + /// Resolution status. + #[serde(default)] + pub resolution_status: Option, + /// Warning identifier. + #[serde(default)] + pub warning_id: Option, +} + +/// A fraud warning attached to a customer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct FraudWarning { + /// Warning identifier. + #[serde(default)] + pub id: Option, + /// Warning name. + #[serde(default)] + pub name: Option, + /// The operation that triggered the warning. + #[serde(default)] + pub operation: Option, + /// The warning score. + #[serde(default)] + pub score: Option, +} + +/// Anti-money-laundering match flags for a customer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct AmlHits { + /// Whether a sanctions match was found. + pub has_sanction_match: bool, + /// Whether a politically-exposed-person match was found. + pub has_pep_match: bool, + /// Whether a watchlist match was found. + pub has_watchlist_match: bool, + /// Whether a crime-list match was found. + pub has_crimelist_match: bool, + /// Whether an adverse-media match was found. + pub has_adversemedia_match: bool, +} + +/// A customer's transaction limits. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CustomerLimit { + /// Per-transaction limit, in cents. + pub per_transaction: i64, + /// Daily limit, in cents. + pub daily: i64, + /// Monthly limit, in cents. + pub monthly: i64, +} + +/// A customer (individual or business). +/// +/// The API returns a single object whose populated fields depend on the +/// customer's [`type`](Customer::type_) and [`kyc_type`](Customer::kyc_type); +/// variant-specific fields are absent for other variants, so they are all +/// optional. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Customer { + /// Customer identifier (`re_` prefix). + pub id: String, + /// Public customer identifier (present on the list response). + #[serde(default)] + pub customer_id: Option, + /// Whether the account is an individual or a business. + #[serde(rename = "type")] + pub type_: AccountClass, + /// The KYC/KYB tier. + pub kyc_type: KycType, + /// The current KYC/KYB status. + pub kyc_status: KycStatus, + /// Whether the customer has accepted the terms of service. + #[serde(default)] + pub is_tos_accepted: Option, + /// KYC/KYB warnings. + #[serde(default)] + pub kyc_warnings: Option>, + /// Fraud warnings. + #[serde(default)] + pub fraud_warnings: Option>, + /// Email address. + #[serde(default)] + pub email: Option, + /// Tax identifier. + #[serde(default)] + pub tax_id: Option, + /// First address line. + #[serde(default)] + pub address_line_1: Option, + /// Second address line. + #[serde(default)] + pub address_line_2: Option, + /// City. + #[serde(default)] + pub city: Option, + /// State, province, or region. + #[serde(default)] + pub state_province_region: Option, + /// Country. + #[serde(default)] + pub country: Option, + /// Postal code. + #[serde(default)] + pub postal_code: Option, + /// IP address captured at creation. + #[serde(default)] + pub ip_address: Option, + /// Avatar / logo image URL. + #[serde(default)] + pub image_url: Option, + /// Phone number. + #[serde(default)] + pub phone_number: Option, + /// Proof-of-address document type. + #[serde(default)] + pub proof_of_address_doc_type: Option, + /// Proof-of-address document URL. + #[serde(default)] + pub proof_of_address_doc_file: Option, + /// First name (individual). + #[serde(default)] + pub first_name: Option, + /// Last name (individual). + #[serde(default)] + pub last_name: Option, + /// Date of birth (individual). + #[serde(default)] + pub date_of_birth: Option, + /// Country that issued the identification document (individual). + #[serde(default)] + pub id_doc_country: Option, + /// Identification document type (individual). + #[serde(default)] + pub id_doc_type: Option, + /// URL of the front of the identification document (individual). + #[serde(default)] + pub id_doc_front_file: Option, + /// URL of the back of the identification document (individual). + #[serde(default)] + pub id_doc_back_file: Option, + /// Legal name (business). + #[serde(default)] + pub legal_name: Option, + /// Alternate / trade name (business). + #[serde(default)] + pub alternate_name: Option, + /// Formation date (business). + #[serde(default)] + pub formation_date: Option, + /// Website (business). + #[serde(default)] + pub website: Option, + /// Beneficial owners (business). + #[serde(default)] + pub owners: Option>, + /// Incorporation document URL (business). + #[serde(default)] + pub incorporation_doc_file: Option, + /// Proof-of-ownership document URL (business). + #[serde(default)] + pub proof_of_ownership_doc_file: Option, + /// Source-of-funds document type. + #[serde(default)] + pub source_of_funds_doc_type: Option, + /// Source-of-funds document URL. + #[serde(default)] + pub source_of_funds_doc_file: Option, + /// Selfie image URL. + #[serde(default)] + pub selfie_file: Option, + /// Front image of the individual holding their document (enhanced KYC). + #[serde(default)] + pub individual_holding_doc_front_file: Option, + /// Purpose of transactions. + #[serde(default)] + pub purpose_of_transactions: Option, + /// Explanation for the purpose of transactions. + #[serde(default)] + pub purpose_of_transactions_explanation: Option, + /// Whether the customer is a "for benefit of" account. + #[serde(default)] + pub is_fbo: Option, + /// Account purpose. + #[serde(default)] + pub account_purpose: Option, + /// Free-form account-purpose description (when `account_purpose` is `other`). + #[serde(default)] + pub account_purpose_other: Option, + /// Business legal structure. + #[serde(default)] + pub business_type: Option, + /// Business description. + #[serde(default)] + pub business_description: Option, + /// Business NAICS industry code. + #[serde(default)] + pub business_industry: Option, + /// Estimated annual revenue band. + #[serde(default)] + pub estimated_annual_revenue: Option, + /// Source of wealth. + #[serde(default)] + pub source_of_wealth: Option, + /// Whether the business is publicly traded. + #[serde(default)] + pub publicly_traded: Option, + /// Occupation. + #[serde(default)] + pub occupation: Option, + /// Caller-supplied external identifier. + #[serde(default)] + pub external_id: Option, + /// Owning instance identifier. + #[serde(default)] + pub instance_id: Option, + /// Terms-of-service record identifier. + #[serde(default)] + pub tos_id: Option, + /// AiPrise validation key. + #[serde(default)] + pub aiprise_validation_key: Option, + /// Anti-money-laundering screening status. + #[serde(default)] + pub aml_status: Option, + /// Anti-money-laundering match flags. + #[serde(default)] + pub aml_hits: Option, + /// The customer's transaction limits. + #[serde(default)] + pub limit: Option, + /// Creation timestamp. + #[serde(default)] + pub created_at: Option, + /// Last-update timestamp. + #[serde(default)] + pub updated_at: Option, +} + +/// Query parameters for listing customers. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ListCustomersParams { + /// Maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Number of items to skip. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Cursor: return items after this object ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_after: Option, + /// Cursor: return items before this object ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_before: Option, + /// Filter by full name. + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + /// Filter by customer name. + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_name: Option, + /// Filter by KYC status. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Filter by customer ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_id: Option, + /// Filter by an associated bank-account ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub bank_account_id: Option, + /// Filter by country. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, +} + +impl ListCustomersParams { + /// Creates an empty set of list parameters. + pub fn new() -> Self { + Self::default() + } + + /// Sets the maximum number of items to return. + pub fn limit(mut self, limit: crate::common::Limit) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the number of items to skip. + pub fn offset(mut self, offset: crate::common::Offset) -> Self { + self.offset = Some(offset); + self + } + + /// Filters by full name. + pub fn full_name(mut self, value: impl Into) -> Self { + self.full_name = Some(value.into()); + self + } + + /// Filters by customer name. + pub fn customer_name(mut self, value: impl Into) -> Self { + self.customer_name = Some(value.into()); + self + } + + /// Filters by KYC status. + pub fn status(mut self, status: KycStatus) -> Self { + self.status = Some(status); + self + } + + /// Filters by customer ID. + pub fn customer_id(mut self, value: impl Into) -> Self { + self.customer_id = Some(value.into()); + self + } + + /// Filters by an associated bank-account ID. + pub fn bank_account_id(mut self, value: impl Into) -> Self { + self.bank_account_id = Some(value.into()); + self + } + + /// Filters by country. + pub fn country(mut self, country: impl Into) -> Self { + self.country = Some(country.into()); + self + } +} + +/// The response returned by the create-customer endpoints. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreateCustomerResponse { + /// The new customer's primary identifier. + pub id: String, + /// The new customer's customer identifier. + pub customer_id: String, +} + +/// Input for [`super::Customers::create_individual_with_standard_kyc`]. +/// +/// The `type` and `kyc_type` discriminators are injected by the method. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct CreateIndividualWithStandardKycInput { + /// Country. + pub country: Country, + /// Email address. + pub email: String, + /// Account purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose: Option, + /// First address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_1: Option, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// City. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + /// Date of birth (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, + /// Caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// First name. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_name: Option, + /// URL of the back of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_back_file: Option, + /// Country that issued the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_country: Option, + /// URL of the front of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_front_file: Option, + /// Identification document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_type: Option, + /// Avatar image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + /// IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Last name. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_name: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Postal code. + #[serde(skip_serializing_if = "Option::is_none")] + pub postal_code: Option, + /// Proof-of-address document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_file: Option, + /// Proof-of-address document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_type: Option, + /// Selfie image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub selfie_file: Option, + /// Source-of-funds document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_file: Option, + /// Source-of-funds document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_type: Option, + /// Source of wealth. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_wealth: Option, + /// State, province, or region. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_province_region: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Terms-of-service record identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_id: Option, + /// Free-form account-purpose description. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose_other: Option, + /// Occupation. + #[serde(skip_serializing_if = "Option::is_none")] + pub occupation: Option, + /// Estimated annual revenue band. + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_annual_revenue: Option, +} + +/// Input for [`super::Customers::create_individual_with_enhanced_kyc`]. +/// +/// The `type` and `kyc_type` discriminators are injected by the method. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct CreateIndividualWithEnhancedKycInput { + /// Country. + pub country: Country, + /// Email address. + pub email: String, + /// Account purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose: Option, + /// First address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_1: Option, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// City. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + /// Date of birth (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, + /// Caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// First name. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_name: Option, + /// URL of the back of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_back_file: Option, + /// Country that issued the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_country: Option, + /// URL of the front of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_front_file: Option, + /// Identification document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_type: Option, + /// Avatar image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + /// IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Last name. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_name: Option, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Postal code. + #[serde(skip_serializing_if = "Option::is_none")] + pub postal_code: Option, + /// Proof-of-address document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_file: Option, + /// Proof-of-address document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_type: Option, + /// Purpose of transactions. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose_of_transactions: Option, + /// Explanation for the purpose of transactions. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose_of_transactions_explanation: Option, + /// Selfie image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub selfie_file: Option, + /// Source-of-funds document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_file: Option, + /// Source-of-funds document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_type: Option, + /// Source of wealth. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_wealth: Option, + /// State, province, or region. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_province_region: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Terms-of-service record identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_id: Option, + /// Free-form account-purpose description. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose_other: Option, + /// Occupation. + #[serde(skip_serializing_if = "Option::is_none")] + pub occupation: Option, + /// Estimated annual revenue band. + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_annual_revenue: Option, +} + +/// Input for [`super::Customers::create_business_with_standard_kyb`]. +/// +/// The `type` and `kyc_type` discriminators are injected by the method. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct CreateBusinessWithStandardKybInput { + /// Country. + pub country: Country, + /// Email address. + pub email: String, + /// Account purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose: Option, + /// First address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_1: Option, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// Alternate / trade name. + #[serde(skip_serializing_if = "Option::is_none")] + pub alternate_name: Option, + /// Business description. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_description: Option, + /// Business NAICS industry code. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Business legal structure. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_type: Option, + /// City. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + /// Estimated annual revenue band. + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_annual_revenue: Option, + /// Caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// Formation date (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub formation_date: Option, + /// Logo image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + /// Incorporation document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub incorporation_doc_file: Option, + /// IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Legal name. + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, + /// Beneficial owners. + #[serde(skip_serializing_if = "Option::is_none")] + pub owners: Option>, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Postal code. + #[serde(skip_serializing_if = "Option::is_none")] + pub postal_code: Option, + /// Proof-of-address document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_file: Option, + /// Proof-of-address document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_type: Option, + /// Proof-of-ownership document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_ownership_doc_file: Option, + /// Whether the business is publicly traded. + #[serde(skip_serializing_if = "Option::is_none")] + pub publicly_traded: Option, + /// Source-of-funds document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_file: Option, + /// Source-of-funds document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_type: Option, + /// Source of wealth. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_wealth: Option, + /// State, province, or region. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_province_region: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Terms-of-service record identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_id: Option, + /// Website. + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + /// Free-form account-purpose description. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose_other: Option, + /// Occupation. + #[serde(skip_serializing_if = "Option::is_none")] + pub occupation: Option, +} + +/// Input for [`super::Customers::update`]; every field is optional. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct UpdateCustomerInput { + /// Account purpose. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose: Option, + /// First address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_1: Option, + /// Second address line. + #[serde(skip_serializing_if = "Option::is_none")] + pub address_line_2: Option, + /// Alternate / trade name. + #[serde(skip_serializing_if = "Option::is_none")] + pub alternate_name: Option, + /// Business description. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_description: Option, + /// Business NAICS industry code. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_industry: Option, + /// Business legal structure. + #[serde(skip_serializing_if = "Option::is_none")] + pub business_type: Option, + /// City. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + /// Country. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + /// Date of birth (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, + /// Email address. + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + /// Estimated annual revenue band. + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_annual_revenue: Option, + /// Caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// First name. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_name: Option, + /// Formation date (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub formation_date: Option, + /// URL of the back of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_back_file: Option, + /// Country that issued the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_country: Option, + /// URL of the front of the identification document. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_front_file: Option, + /// Identification document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub id_doc_type: Option, + /// Image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + /// Incorporation document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub incorporation_doc_file: Option, + /// IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Last name. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_name: Option, + /// Legal name. + #[serde(skip_serializing_if = "Option::is_none")] + pub legal_name: Option, + /// Beneficial owners. + #[serde(skip_serializing_if = "Option::is_none")] + pub owners: Option>, + /// Phone number. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + /// Postal code. + #[serde(skip_serializing_if = "Option::is_none")] + pub postal_code: Option, + /// Proof-of-address document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_file: Option, + /// Proof-of-address document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_address_doc_type: Option, + /// Proof-of-ownership document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof_of_ownership_doc_file: Option, + /// Whether the business is publicly traded. + #[serde(skip_serializing_if = "Option::is_none")] + pub publicly_traded: Option, + /// Purpose of transactions. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose_of_transactions: Option, + /// Explanation for the purpose of transactions. + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose_of_transactions_explanation: Option, + /// Selfie image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub selfie_file: Option, + /// Source-of-funds document URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_file: Option, + /// Source-of-funds document type. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_funds_doc_type: Option, + /// Source of wealth. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_of_wealth: Option, + /// State, province, or region. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_province_region: Option, + /// Tax identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_id: Option, + /// Terms-of-service record identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_id: Option, + /// Website. + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + /// Free-form account-purpose description. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_purpose_other: Option, + /// Occupation. + #[serde(skip_serializing_if = "Option::is_none")] + pub occupation: Option, +} + +/// One side (payin or payout) of a customer's aggregate limits. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct LimitWindow { + /// Daily limit, in cents. + pub daily: i64, + /// Monthly limit, in cents. + pub monthly: i64, +} + +/// A customer's payin and payout limits. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CustomerLimits { + /// Payin limits. + pub payin: LimitWindow, + /// Payout limits. + pub payout: LimitWindow, +} + +/// The response returned by [`super::Customers::get_limits`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct GetCustomerLimitsResponse { + /// The customer's limits. + pub limits: CustomerLimits, +} + +/// A limit-increase request. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct LimitIncrease { + /// Request identifier. + pub id: String, + /// The receiver the request belongs to. + pub receiver_id: String, + /// The current request status. + pub status: LimitIncreaseStatus, + /// Requested per-transaction limit, in cents. + #[serde(default)] + pub per_transaction: Option, + /// Requested daily limit, in cents. + #[serde(default)] + pub daily: Option, + /// Requested monthly limit, in cents. + #[serde(default)] + pub monthly: Option, + /// Approved per-transaction limit, in cents. + #[serde(default)] + pub approved_per_transaction: Option, + /// Approved daily limit, in cents. + #[serde(default)] + pub approved_daily: Option, + /// Approved monthly limit, in cents. + #[serde(default)] + pub approved_monthly: Option, + /// The supporting document's type. + #[serde(default)] + pub supporting_document_type: Option, + /// URL of the supporting document. + #[serde(default)] + pub supporting_document_file: Option, + /// Creation timestamp. + #[serde(default)] + pub created_at: Option, + /// Last-update timestamp. + #[serde(default)] + pub updated_at: Option, +} + +/// Input for [`super::Customers::request_limit_increase`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RequestLimitIncreaseInput { + /// Requested per-transaction limit, in cents. + pub per_transaction: i64, + /// Requested daily limit, in cents. + pub daily: i64, + /// Requested monthly limit, in cents. + pub monthly: i64, + /// The supporting document's type. + pub supporting_document_type: SupportingDocumentType, + /// URL of the supporting document. + pub supporting_document_file: String, +} + +/// The response returned by [`super::Customers::request_limit_increase`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct RequestLimitIncreaseResponse { + /// The new limit-increase request's identifier. + pub id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kyc_type_round_trips() { + assert_eq!( + serde_json::to_string(&KycType::Enhanced).unwrap(), + "\"enhanced\"" + ); + assert_eq!( + serde_json::from_str::("\"light\"").unwrap(), + KycType::Light + ); + assert_eq!( + serde_json::from_str::("\"future\"").unwrap(), + KycType::Unknown("future".to_string()) + ); + } + + #[test] + fn business_type_round_trips() { + assert_eq!( + serde_json::to_string(&BusinessType::SoleProprietorship).unwrap(), + "\"sole_proprietorship\"" + ); + assert_eq!( + serde_json::from_str::("\"non_profit\"").unwrap(), + BusinessType::NonProfit + ); + } + + #[test] + fn supporting_document_type_round_trips() { + assert_eq!( + serde_json::to_string(&SupportingDocumentType::BusinessTaxReturn).unwrap(), + "\"business_tax_return\"" + ); + } + + #[test] + fn limit_increase_status_round_trips() { + assert_eq!( + serde_json::from_str::("\"in_review\"").unwrap(), + LimitIncreaseStatus::InReview + ); + } + + #[test] + fn identification_document_uses_uppercase_wire() { + assert_eq!( + serde_json::to_string(&IdentificationDocument::IdCard).unwrap(), + "\"ID_CARD\"" + ); + assert_eq!( + serde_json::from_str::("\"UTILITY_BILL\"").unwrap(), + ProofOfAddressDocType::UtilityBill + ); + } + + #[test] + fn owner_tax_type_uses_uppercase_wire() { + assert_eq!( + serde_json::to_string(&OwnerTaxType::Ssn).unwrap(), + "\"SSN\"" + ); + assert_eq!( + serde_json::from_str::("\"ITIN\"").unwrap(), + OwnerTaxType::Itin + ); + } + + #[test] + fn create_individual_input_injects_no_discriminators_and_skips_none() { + let input = CreateIndividualWithStandardKycInput { + country: Country::from("BR"), + email: "a@b.com".to_string(), + first_name: Some("Ana".to_string()), + ..Default::default() + }; + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json["country"], "BR"); + assert_eq!(json["email"], "a@b.com"); + assert_eq!(json["first_name"], "Ana"); + // Unset optionals are omitted; discriminators are added by the method. + assert!(json.get("last_name").is_none()); + assert!(json.get("type").is_none()); + assert!(json.get("kyc_type").is_none()); + } +} diff --git a/src/resources/fees/mod.rs b/src/resources/fees/mod.rs new file mode 100644 index 0000000..87937ea --- /dev/null +++ b/src/resources/fees/mod.rs @@ -0,0 +1,33 @@ +//! The `fees` resource: the per-rail and per-chain fee schedule configured for +//! an instance. + +mod types; + +pub use types::{Fee, FeeOptions}; + +use std::sync::Arc; + +use crate::client::Inner; +use crate::error::Result; + +/// Handle for the `fees` resource. +/// +/// Obtained from the `fees` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Fees { + client: Arc, +} + +impl Fees { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Returns the fee schedule for the instance. + /// + /// `GET /instances/{instance_id}/billing/fees` + pub async fn get(&self) -> Result { + let path = format!("/instances/{}/billing/fees", self.client.instance_id); + self.client.get(&path).await + } +} diff --git a/src/resources/fees/types.rs b/src/resources/fees/types.rs new file mode 100644 index 0000000..4941314 --- /dev/null +++ b/src/resources/fees/types.rs @@ -0,0 +1,161 @@ +//! Request and response types for the `fees` resource. + +use serde::Deserialize; + +/// The fee configuration for a single rail or chain. +/// +/// Flat amounts are integer cents; percentage amounts are integer basis points. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct FeeOptions { + /// Payin flat fee, in cents. + pub payin_flat: i64, + /// Payin percentage fee, in basis points. + pub payin_percentage: i64, + /// Payout flat fee, in cents. + pub payout_flat: i64, + /// Payout percentage fee, in basis points. + pub payout_percentage: i64, +} + +/// The full fee schedule for an instance, returned by `Fees::get`. +/// +/// Each field is the [`FeeOptions`] for one rail or chain. The keys are the +/// literal wire field names (note the non-`Rail` names `domestic_wire`, +/// `ach_colombia`, `transfers_3`). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Fee { + /// Fee record identifier (prefix `fe_`). + pub id: String, + /// The instance these fees belong to. + pub instance_id: String, + /// ACH (US) fees. + pub ach: FeeOptions, + /// Domestic wire (US) fees. + pub domestic_wire: FeeOptions, + /// RTP (US) fees. + pub rtp: FeeOptions, + /// International SWIFT fees. + pub international_swift: FeeOptions, + /// PIX (Brazil) fees. + pub pix: FeeOptions, + /// PIX Safe (Brazil) fees. + pub pix_safe: FeeOptions, + /// TED (Brazil) fees. May be absent. + #[serde(default)] + pub ted: Option, + /// ACH Colombia fees. + pub ach_colombia: FeeOptions, + /// Transfers 3.0 fees. + pub transfers_3: FeeOptions, + /// SPEI (Mexico) fees. + pub spei: FeeOptions, + /// SEPA (Europe) fees. + pub sepa: FeeOptions, + /// Tron chain fees. + pub tron: FeeOptions, + /// Ethereum chain fees. + pub ethereum: FeeOptions, + /// Polygon chain fees. + pub polygon: FeeOptions, + /// Base chain fees. + pub base: FeeOptions, + /// Arbitrum chain fees. + pub arbitrum: FeeOptions, + /// Stellar chain fees. + pub stellar: FeeOptions, + /// Solana chain fees. + pub solana: FeeOptions, + /// ISO-8601 creation timestamp. + pub created_at: String, + /// ISO-8601 last-update timestamp. + pub updated_at: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fee_parses_with_optional_ted_absent() { + let opt = serde_json::json!({ + "payin_flat": 40, + "payin_percentage": 50, + "payout_flat": 40, + "payout_percentage": 50 + }); + let body = serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "ach": opt, + "domestic_wire": opt, + "rtp": opt, + "international_swift": opt, + "pix": opt, + "pix_safe": opt, + "ach_colombia": opt, + "transfers_3": opt, + "spei": opt, + "sepa": opt, + "tron": opt, + "ethereum": opt, + "polygon": opt, + "base": opt, + "arbitrum": opt, + "stellar": opt, + "solana": opt, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z" + }); + + let fee: Fee = serde_json::from_value(body).unwrap(); + assert_eq!(fee.id, "fe_123"); + assert_eq!(fee.ach.payin_flat, 40); + assert_eq!(fee.sepa.payout_percentage, 50); + assert!(fee.ted.is_none()); + } + + #[test] + fn fee_parses_with_ted_present() { + let opt = serde_json::json!({ + "payin_flat": 1, + "payin_percentage": 2, + "payout_flat": 3, + "payout_percentage": 4 + }); + let mut body = serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z" + }); + for key in [ + "ach", + "domestic_wire", + "rtp", + "international_swift", + "pix", + "pix_safe", + "ted", + "ach_colombia", + "transfers_3", + "spei", + "sepa", + "tron", + "ethereum", + "polygon", + "base", + "arbitrum", + "stellar", + "solana", + ] { + body[key] = opt.clone(); + } + + let fee: Fee = serde_json::from_value(body).unwrap(); + let ted = fee.ted.expect("ted should be present"); + assert_eq!(ted.payin_flat, 1); + assert_eq!(ted.payout_percentage, 4); + } +} diff --git a/src/resources/instances/mod.rs b/src/resources/instances/mod.rs new file mode 100644 index 0000000..85657fc --- /dev/null +++ b/src/resources/instances/mod.rs @@ -0,0 +1,133 @@ +//! The `instances` resource: manage an instance, its members, ownership, terms +//! of service, and webhook endpoints. + +mod tos; +mod types; +mod webhook_endpoints; + +pub use tos::Tos; +pub use types::{ + CreateWebhookEndpointInput, CreateWebhookEndpointResponse, InitiateTosInput, + InitiateTosResponse, Member, PortalAccess, UpdateInstanceInput, UserRole, WebhookEndpoint, + WebhookEndpointSecret, +}; +pub use webhook_endpoints::WebhookEndpoints; + +use std::sync::Arc; + +use reqwest::Method; +use serde::Serialize; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::Result; +use crate::internal::encode_path_segment; + +/// Handle for the `instances` resource. +/// +/// Obtained from the `instances` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Instances { + client: Arc, + /// Terms-of-service sub-resource. + pub tos: Tos, + /// Webhook-endpoints sub-resource. + pub webhook_endpoints: WebhookEndpoints, +} + +impl Instances { + pub(crate) fn new(client: Arc) -> Self { + Self { + tos: Tos::new(Arc::clone(&client)), + webhook_endpoints: WebhookEndpoints::new(Arc::clone(&client)), + client, + } + } + + /// Lists the members of the instance. + /// + /// `GET /instances/{id}/members` + pub async fn get_members(&self) -> Result> { + let path = format!("/instances/{}/members", self.client.instance_id); + self.client.get(&path).await + } + + /// Updates the instance's settings. + /// + /// `PUT /instances/{id}` + pub async fn update(&self, body: &UpdateInstanceInput) -> Result { + let path = format!("/instances/{}", self.client.instance_id); + self.client + .request(Method::PUT, &path, None::<&()>, Some(body)) + .await + } + + /// Deletes the instance. + /// + /// `DELETE /instances/{id}` + pub async fn delete(&self) -> Result { + let path = format!("/instances/{}", self.client.instance_id); + self.client + .request(Method::DELETE, &path, None::<&()>, None::<&()>) + .await + } + + /// Updates a member's role. + /// + /// `PUT /instances/{id}/members/{user_id}` + pub async fn update_member_role( + &self, + user_id: impl AsRef, + user_role: UserRole, + ) -> Result { + #[derive(Serialize)] + struct Body { + user_role: UserRole, + } + + let path = format!( + "/instances/{}/members/{}", + self.client.instance_id, + encode_path_segment(user_id.as_ref()) + ); + self.client + .request(Method::PUT, &path, None::<&()>, Some(&Body { user_role })) + .await + } + + /// Removes a member from the instance. + /// + /// `DELETE /instances/{id}/members/{user_id}` + pub async fn delete_member(&self, user_id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/members/{}", + self.client.instance_id, + encode_path_segment(user_id.as_ref()) + ); + self.client + .request(Method::DELETE, &path, None::<&()>, None::<&()>) + .await + } + + /// Transfers instance ownership to another current member. + /// + /// `POST /instances/{id}/ownership` + pub async fn migrate_ownership(&self, user_id: impl Into) -> Result { + #[derive(Serialize)] + struct Body { + user_id: String, + } + + let path = format!("/instances/{}/ownership", self.client.instance_id); + self.client + .request( + Method::POST, + &path, + None::<&()>, + Some(&Body { + user_id: user_id.into(), + }), + ) + .await + } +} diff --git a/src/resources/instances/tos.rs b/src/resources/instances/tos.rs new file mode 100644 index 0000000..6fd7c48 --- /dev/null +++ b/src/resources/instances/tos.rs @@ -0,0 +1,32 @@ +//! The `tos` sub-resource: initiate a hosted terms-of-service flow. + +use std::sync::Arc; + +use crate::client::Inner; +use crate::error::Result; +use crate::resources::instances::types::{InitiateTosInput, InitiateTosResponse}; + +/// Handle for the terms-of-service sub-resource, reached via +/// [`Instances::tos`](crate::Instances). +/// +#[derive(Clone)] +pub struct Tos { + client: Arc, +} + +impl Tos { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Initiates a hosted terms-of-service flow and returns the URL to send the + /// customer to. + /// + /// `POST /e/instances/{instance_id}/tos` + pub async fn initiate(&self, body: &InitiateTosInput) -> Result { + let path = format!("/e/instances/{}/tos", self.client.instance_id); + self.client + .request(reqwest::Method::POST, &path, None::<&()>, Some(body)) + .await + } +} diff --git a/src/resources/instances/types.rs b/src/resources/instances/types.rs new file mode 100644 index 0000000..ef71f05 --- /dev/null +++ b/src/resources/instances/types.rs @@ -0,0 +1,233 @@ +//! Request and response types for the `instances` resource and its +//! `tos` / `webhook_endpoints` sub-resources. + +use serde::{Deserialize, Serialize}; + +use crate::common::WebhookEvent; +use crate::common::open_enum; + +open_enum! { + /// A member's role within an instance. + pub enum UserRole { + /// Instance owner. + Owner => "owner", + /// Administrator. + Admin => "admin", + /// Finance. + Finance => "finance", + /// Checker. + Checker => "checker", + /// Operations. + Operations => "operations", + /// Developer. + Developer => "developer", + /// Viewer. + Viewer => "viewer", + } +} + +/// A member of an instance, returned by `Instances::get_members`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Member { + /// Member (user) identifier, e.g. `"us_000000000000"`. + pub id: String, + /// Email address. + pub email: String, + /// First name. + pub first_name: String, + /// Middle name, when present. + #[serde(default)] + pub middle_name: Option, + /// Last name. + pub last_name: String, + /// Avatar image URL. + pub image_url: String, + /// Whether the member has registered a passkey. + #[serde(default)] + pub has_passkey: Option, + /// The member's role within the instance. + pub role: UserRole, + /// Creation timestamp (ISO 8601). + pub created_at: String, +} + +/// Body for `Instances::update`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UpdateInstanceInput { + /// Display name for the instance (required). + pub name: String, + /// URL receivers are redirected to after accepting an invite. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_invite_redirect_url: Option, + /// Whether email notifications are enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub email_notifications: Option, + /// Whether a passkey is required for members. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_passkey: Option, +} + +impl UpdateInstanceInput { + /// Creates an update body with only the required `name` field set. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + receiver_invite_redirect_url: None, + email_notifications: None, + require_passkey: None, + } + } +} + +/// Body for `Instances::initiate` terms of service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct InitiateTosInput { + /// Idempotency key (UUID) for the request (required). + pub idempotency_key: String, + /// The customer (receiver) the terms apply to. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_id: Option, + /// URL to redirect to after acceptance. + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect_url: Option, +} + +impl InitiateTosInput { + /// Creates an initiate body with only the required `idempotency_key` set. + pub fn new(idempotency_key: impl Into) -> Self { + Self { + idempotency_key: idempotency_key.into(), + receiver_id: None, + redirect_url: None, + } + } +} + +/// Response from `Tos::initiate`: a hosted URL to complete the flow. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct InitiateTosResponse { + /// The hosted terms-of-service URL. + pub url: String, +} + +/// A webhook endpoint, returned by `WebhookEndpoints::list`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct WebhookEndpoint { + /// Endpoint identifier, e.g. `"we_000000000000"`. + pub id: String, + /// The destination URL. + pub url: String, + /// The events this endpoint is subscribed to. + #[serde(default)] + pub events: Vec, + /// Timestamp of the last delivered event, when any. + #[serde(default)] + pub last_event_at: Option, + /// The owning instance identifier. + pub instance_id: String, + /// Creation timestamp (ISO 8601). + pub created_at: String, + /// Last update timestamp (ISO 8601). + pub updated_at: String, +} + +/// Body for `WebhookEndpoints::create`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateWebhookEndpointInput { + /// The destination URL (required). + pub url: String, + /// The events to subscribe to (required). + pub events: Vec, +} + +impl CreateWebhookEndpointInput { + /// Creates a webhook-endpoint body from a URL and a set of events. + pub fn new(url: impl Into, events: Vec) -> Self { + Self { + url: url.into(), + events, + } + } +} + +/// Response carrying the id of a newly created webhook endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreateWebhookEndpointResponse { + /// The created endpoint's identifier, e.g. `"we_000000000000"`. + pub id: String, +} + +/// Response from `WebhookEndpoints::get_secret`: the signing secret. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct WebhookEndpointSecret { + /// The signing secret (`whsec_…`) used to verify webhook payloads. + pub key: String, +} + +/// Response from `WebhookEndpoints::get_portal_access_url`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PortalAccess { + /// The Svix application portal URL. + pub url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_role_round_trips() { + assert_eq!( + serde_json::to_string(&UserRole::Owner).unwrap(), + "\"owner\"" + ); + assert_eq!( + serde_json::from_str::("\"checker\"").unwrap(), + UserRole::Checker + ); + assert_eq!( + serde_json::from_str::("\"superuser\"").unwrap(), + UserRole::Unknown("superuser".to_string()) + ); + } + + #[test] + fn update_instance_input_skips_absent_optionals() { + let body = UpdateInstanceInput::new("Acme"); + assert_eq!( + serde_json::to_value(&body).unwrap(), + serde_json::json!({ "name": "Acme" }) + ); + + let full = UpdateInstanceInput { + name: "Acme".to_string(), + receiver_invite_redirect_url: Some("https://example.com".to_string()), + email_notifications: Some(false), + require_passkey: Some(true), + }; + assert_eq!( + serde_json::to_value(&full).unwrap(), + serde_json::json!({ + "name": "Acme", + "receiver_invite_redirect_url": "https://example.com", + "email_notifications": false, + "require_passkey": true + }) + ); + } + + #[test] + fn initiate_tos_input_skips_absent_optionals() { + let body = InitiateTosInput::new("123e4567-e89b-12d3-a456-426614174000"); + assert_eq!( + serde_json::to_value(&body).unwrap(), + serde_json::json!({ "idempotency_key": "123e4567-e89b-12d3-a456-426614174000" }) + ); + } +} diff --git a/src/resources/instances/webhook_endpoints.rs b/src/resources/instances/webhook_endpoints.rs new file mode 100644 index 0000000..00df70b --- /dev/null +++ b/src/resources/instances/webhook_endpoints.rs @@ -0,0 +1,86 @@ +//! The `webhook_endpoints` sub-resource: manage webhook delivery endpoints. + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::Result; +use crate::internal::encode_path_segment; +use crate::resources::instances::types::{ + CreateWebhookEndpointInput, CreateWebhookEndpointResponse, PortalAccess, WebhookEndpoint, + WebhookEndpointSecret, +}; + +/// Handle for the webhook-endpoints sub-resource, reached via +/// [`Instances::webhook_endpoints`](crate::Instances). +/// +#[derive(Clone)] +pub struct WebhookEndpoints { + client: Arc, +} + +impl WebhookEndpoints { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the instance's webhook endpoints. + /// + /// `GET /instances/{instance_id}/webhook-endpoints` + pub async fn list(&self) -> Result> { + let path = format!("/instances/{}/webhook-endpoints", self.client.instance_id); + self.client.get(&path).await + } + + /// Creates a webhook endpoint subscribed to the given events. + /// + /// `POST /instances/{instance_id}/webhook-endpoints` + pub async fn create( + &self, + body: &CreateWebhookEndpointInput, + ) -> Result { + let path = format!("/instances/{}/webhook-endpoints", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(body)) + .await + } + + /// Deletes a webhook endpoint. + /// + /// `DELETE /instances/{instance_id}/webhook-endpoints/{id}` + pub async fn delete(&self, id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/webhook-endpoints/{}", + self.client.instance_id, + encode_path_segment(id.as_ref()) + ); + self.client + .request(Method::DELETE, &path, None::<&()>, None::<&()>) + .await + } + + /// Retrieves the signing secret for a webhook endpoint. + /// + /// `GET /instances/{instance_id}/webhook-endpoints/{id}/secret` + pub async fn get_secret(&self, id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/webhook-endpoints/{}/secret", + self.client.instance_id, + encode_path_segment(id.as_ref()) + ); + self.client.get(&path).await + } + + /// Retrieves a URL granting access to the Svix consumer portal. + /// + /// `GET /instances/{instance_id}/webhook-endpoints/portal-access` + pub async fn get_portal_access_url(&self) -> Result { + let path = format!( + "/instances/{}/webhook-endpoints/portal-access", + self.client.instance_id + ); + self.client.get(&path).await + } +} diff --git a/src/resources/mod.rs b/src/resources/mod.rs new file mode 100644 index 0000000..8fefe7f --- /dev/null +++ b/src/resources/mod.rs @@ -0,0 +1,18 @@ +//! API resources. +//! +//! Each resource groups a set of related endpoints. Resources are accessed +//! through the [`BlindPay`](crate::BlindPay) client (for example +//! [`BlindPay::available`](crate::BlindPay::available)). + +pub mod available; +pub mod customers; +pub mod fees; +pub mod instances; +pub mod partner_fees; +pub mod payins; +pub mod payouts; +pub mod quotes; +pub mod transfers; +pub mod upload; +pub mod virtual_accounts; +pub mod wallets; diff --git a/src/resources/partner_fees/mod.rs b/src/resources/partner_fees/mod.rs new file mode 100644 index 0000000..b4e120c --- /dev/null +++ b/src/resources/partner_fees/mod.rs @@ -0,0 +1,90 @@ +//! The `partner_fees` resource: configure payin/payout fees that are split out to +//! a partner. + +mod types; + +pub use types::{CreatePartnerFeeInput, PartnerFee}; + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::{Error, Result}; +use crate::internal::encode_path_segment; + +/// Handle for the `partner_fees` resource. +/// +/// Obtained from the `partner_fees` field of a [`BlindPay`](crate::BlindPay) +/// client. +#[derive(Clone)] +pub struct PartnerFees { + client: Arc, +} + +impl PartnerFees { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists every partner fee configured for the instance. + /// + /// `GET /instances/{instance_id}/partner-fees` + pub async fn list(&self) -> Result> { + let path = format!("/instances/{}/partner-fees", self.client.instance_id); + self.client.get(&path).await + } + + /// Retrieves a single partner fee by id. + /// + /// `GET /instances/{instance_id}/partner-fees/{id}` + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `id` is empty. + pub async fn get(&self, id: impl AsRef) -> Result { + let id = id.as_ref().trim(); + if id.is_empty() { + return Err(Error::Config("partner fee id cannot be empty".to_string())); + } + let path = format!( + "/instances/{}/partner-fees/{}", + self.client.instance_id, + encode_path_segment(id) + ); + self.client.get(&path).await + } + + /// Creates a partner fee. + /// + /// `POST /instances/{instance_id}/partner-fees` + pub async fn create(&self, input: &CreatePartnerFeeInput) -> Result { + let path = format!("/instances/{}/partner-fees", self.client.instance_id); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Deletes a partner fee by id. + /// + /// `DELETE /instances/{instance_id}/partner-fees/{id}` + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `id` is empty. + pub async fn delete(&self, id: impl AsRef) -> Result { + let id = id.as_ref().trim(); + if id.is_empty() { + return Err(Error::Config("partner fee id cannot be empty".to_string())); + } + let path = format!( + "/instances/{}/partner-fees/{}", + self.client.instance_id, + encode_path_segment(id) + ); + self.client + .request::<_, (), ()>(Method::DELETE, &path, None, None) + .await + } +} diff --git a/src/resources/partner_fees/types.rs b/src/resources/partner_fees/types.rs new file mode 100644 index 0000000..2d745cf --- /dev/null +++ b/src/resources/partner_fees/types.rs @@ -0,0 +1,142 @@ +//! Request and response types for the `partner_fees` resource. + +use serde::{Deserialize, Serialize}; + +/// A partner fee configuration. +/// +/// Models the full `PartnerFee` superset returned by the list endpoint. The +/// `get` and `create` endpoints return a narrower create-output shape that omits +/// `virtual_account_set`, `created_at`, and `updated_at`, so those fields carry +/// `#[serde(default)]` and stay `None` when absent. +/// +/// Fee amounts are integers: the `*_flat_fee` fields are in cents and the +/// `*_percentage_fee` fields are in basis points. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PartnerFee { + /// Unique identifier, prefixed `pf_`. + pub id: String, + /// The instance this partner fee belongs to. + pub instance_id: String, + /// Human-readable display name. + pub name: String, + /// Payout percentage fee, in basis points. + pub payout_percentage_fee: i64, + /// Payout flat fee, in cents. + pub payout_flat_fee: i64, + /// Payin percentage fee, in basis points. + pub payin_percentage_fee: i64, + /// Payin flat fee, in cents. + pub payin_flat_fee: i64, + /// Whether the fee is tied to a virtual account set. Absent on the + /// `get`/`create` responses. + #[serde(default)] + pub virtual_account_set: Option, + /// Creation timestamp. Absent on the `get`/`create` responses. + #[serde(default)] + pub created_at: Option, + /// Last-update timestamp. Absent on the `get`/`create` responses. + #[serde(default)] + pub updated_at: Option, +} + +/// Body for creating a partner fee. +/// +/// All fee amounts are integers (flat fees in cents, percentage fees in basis +/// points). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreatePartnerFeeInput { + /// Human-readable display name. + pub name: String, + /// Payout percentage fee, in basis points. + pub payout_percentage_fee: i64, + /// Payout flat fee, in cents. + pub payout_flat_fee: i64, + /// Payin percentage fee, in basis points. + pub payin_percentage_fee: i64, + /// Payin flat fee, in cents. + pub payin_flat_fee: i64, + /// Whether the fee is tied to a virtual account set. + #[serde(skip_serializing_if = "Option::is_none")] + pub virtual_account_set: Option, +} + +impl CreatePartnerFeeInput { + /// Creates a partner-fee input with the required fields. + pub fn new( + name: impl Into, + payout_percentage_fee: i64, + payout_flat_fee: i64, + payin_percentage_fee: i64, + payin_flat_fee: i64, + ) -> Self { + Self { + name: name.into(), + payout_percentage_fee, + payout_flat_fee, + payin_percentage_fee, + payin_flat_fee, + virtual_account_set: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_input_omits_unset_virtual_account_set() { + let input = CreatePartnerFeeInput::new("Display Name", 100, 1000, 50, 500); + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json["name"], "Display Name"); + assert_eq!(json["payout_percentage_fee"], 100); + assert!(json.get("virtual_account_set").is_none()); + } + + #[test] + fn create_input_serializes_virtual_account_set_when_set() { + let mut input = CreatePartnerFeeInput::new("Display Name", 0, 0, 0, 0); + input.virtual_account_set = Some(true); + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json["virtual_account_set"], true); + } + + #[test] + fn partner_fee_parses_create_out_shape_without_extras() { + let fee: PartnerFee = serde_json::from_value(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 0, + "payout_flat_fee": 0, + "payin_percentage_fee": 0, + "payin_flat_fee": 0 + })) + .unwrap(); + assert_eq!(fee.id, "fe_123"); + assert_eq!(fee.virtual_account_set, None); + assert_eq!(fee.created_at, None); + assert_eq!(fee.updated_at, None); + } + + #[test] + fn partner_fee_parses_full_superset() { + let fee: PartnerFee = serde_json::from_value(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 100, + "payout_flat_fee": 1000, + "payin_percentage_fee": 50, + "payin_flat_fee": 500, + "virtual_account_set": true, + "created_at": "2021-01-01T00:00:00Z", + "updated_at": "2021-01-02T00:00:00Z" + })) + .unwrap(); + assert_eq!(fee.virtual_account_set, Some(true)); + assert_eq!(fee.payout_flat_fee, 1000); + assert_eq!(fee.created_at.as_deref(), Some("2021-01-01T00:00:00Z")); + } +} diff --git a/src/resources/payins/mod.rs b/src/resources/payins/mod.rs new file mode 100644 index 0000000..901a8e9 --- /dev/null +++ b/src/resources/payins/mod.rs @@ -0,0 +1,83 @@ +//! The `payins` resource: inbound (fiat → stablecoin) payments and their quotes. + +mod quotes; +mod types; + +pub use quotes::PayinQuotes; +pub use types::{ + BlindpayBankAccount, BlindpayBankDetails, BlindpayBankParty, CreatePayinInput, + CreatePayinQuoteInput, CreatePayinResponse, ListPayinsParams, PayerRules, Payin, PayinQuote, + PayinQuoteFx, PayinQuoteFxInput, PayinTrackingComplete, PayinTrackingPartnerFee, + PayinTrackingPayment, PayinTrackingTransaction, PayinTransactionStatus, PseDocumentType, + PseInstruction, SwiftReceivingBank, TedInstruction, TransfersInstruction, +}; + +use std::sync::Arc; + +use crate::client::Inner; +use crate::common::ListResponse; +use crate::error::Result; +use crate::internal::encode_path_segment; + +/// Handle for the `payins` resource. +/// +/// Obtained from the `payins` field of a [`BlindPay`](crate::BlindPay) client. The `quotes` sub-handle +/// builds payin quotes and FX previews. +#[derive(Clone)] +pub struct Payins { + client: Arc, + /// Payin quotes (`payin-quotes`) sub-resource. + pub quotes: PayinQuotes, +} + +impl Payins { + pub(crate) fn new(client: Arc) -> Self { + Self { + quotes: PayinQuotes::new(Arc::clone(&client)), + client, + } + } + + /// Creates an EVM payin from a previously created payin quote. + /// + /// `POST /instances/{instance_id}/payins/evm` + pub async fn create_evm(&self, input: &CreatePayinInput) -> Result { + let path = format!("/instances/{}/payins/evm", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Lists payins for the instance. + /// + /// `GET /instances/{instance_id}/payins` + pub async fn list(&self, params: &ListPayinsParams) -> Result> { + let path = format!("/instances/{}/payins", self.client.instance_id); + self.client.get_query(&path, params).await + } + + /// Retrieves a single payin by id. + /// + /// `GET /instances/{instance_id}/payins/{id}` + pub async fn get(&self, payin_id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/payins/{}", + self.client.instance_id, + encode_path_segment(payin_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Retrieves a payin via its public, unauthenticated tracking endpoint. + /// + /// `GET /e/payins/{id}` + /// + /// This `/e/` route is the obfuscated, end-customer tracking endpoint: it is + /// served without instance scoping and is not gated by the API key (the SDK + /// still sends the bearer header). Use [`get`](Payins::get) for the + /// authenticated, instance-scoped lookup. + pub async fn get_track(&self, payin_id: impl AsRef) -> Result { + let path = format!("/e/payins/{}", encode_path_segment(payin_id.as_ref())); + self.client.get(&path).await + } +} diff --git a/src/resources/payins/quotes.rs b/src/resources/payins/quotes.rs new file mode 100644 index 0000000..9aa9b8f --- /dev/null +++ b/src/resources/payins/quotes.rs @@ -0,0 +1,43 @@ +//! The `payins.quotes` sub-resource: payin quotes and FX-rate previews. + +use std::sync::Arc; + +use crate::client::Inner; +use crate::error::Result; + +use super::types::{CreatePayinQuoteInput, PayinQuote, PayinQuoteFx, PayinQuoteFxInput}; + +/// Handle for the `payins.quotes` sub-resource. +/// +/// Obtained from the `quotes` field of a [`Payins`](super::Payins) handle. Cheap +/// to clone — its state is reference-counted. +#[derive(Clone)] +pub struct PayinQuotes { + client: Arc, +} + +impl PayinQuotes { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Creates a payin quote. + /// + /// `POST /instances/{instance_id}/payin-quotes` + pub async fn create(&self, input: &CreatePayinQuoteInput) -> Result { + let path = format!("/instances/{}/payin-quotes", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Previews the FX rate for a prospective payin. + /// + /// `POST /instances/{instance_id}/payin-quotes/fx` + pub async fn get_fx_rate(&self, input: &PayinQuoteFxInput) -> Result { + let path = format!("/instances/{}/payin-quotes/fx", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } +} diff --git a/src/resources/payins/types.rs b/src/resources/payins/types.rs new file mode 100644 index 0000000..9981b1a --- /dev/null +++ b/src/resources/payins/types.rs @@ -0,0 +1,828 @@ +//! Request and response types for the `payins` resource and its `payin-quotes` +//! sub-resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{ + AccountClass, Currency, CurrencyType, Network, PaymentMethod, ProviderName, Token, + TrackingStatus, TransactionStatus, TransfersType, open_enum, +}; + +open_enum! { + /// The payer document type for PSE (Colombian) payins. + pub enum PseDocumentType { + /// Cédula de Ciudadanía. + Cc => "CC", + /// Número de Identificación Tributaria. + Nit => "NIT", + } +} + +open_enum! { + /// The sub-status of a payin's inbound fiat-leg transaction. + pub enum PayinTransactionStatus { + /// The transaction failed. + Failed => "failed", + /// The transaction completed. + Completed => "completed", + } +} + +/// Payer-restriction rules attached to a payin quote. +/// +/// Constrains which payers (by tax id, name, document, etc.) may fund the +/// resulting payin. All fields are optional; only the ones relevant to the +/// chosen payment method apply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PayerRules { + /// Allowed PIX payer tax ids (CPF/CNPJ). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pix_allowed_tax_ids: Option>, + /// Allowed Transfers 3.0 payer tax id (CUIT/CUIL). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transfers_allowed_tax_id: Option, + /// Allowed PSE payer tax ids. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_allowed_tax_ids: Option>, + /// PSE payer full name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_full_name: Option, + /// PSE payer document type (`CC` or `NIT`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_document_type: Option, + /// PSE payer document number. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_document_number: Option, + /// PSE payer email. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_email: Option, + /// PSE payer phone (format `+573` followed by 9 digits). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_phone: Option, + /// PSE payer bank code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pse_bank_code: Option, +} + +/// Input for `PayinQuotes::create` (`POST /instances/{instance_id}/payin-quotes`). +/// +/// Provide exactly one wallet reference — `blockchain_wallet_id` (a `bw_…` id) or +/// `wallet_id` (a custodial `bl_…` id). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreatePayinQuoteInput { + /// Blockchain wallet to credit (`bw_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub blockchain_wallet_id: Option, + /// Custodial wallet to credit (`bl_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub wallet_id: Option, + /// Which side the `request_amount` is denominated in. + pub currency_type: CurrencyType, + /// If `true`, the sender covers the fees; otherwise the receiver does. + pub cover_fees: bool, + /// Requested amount in cents (minimum 500). + pub request_amount: i64, + /// Payin payment method. + pub payment_method: PaymentMethod, + /// Stablecoin token to receive. + pub token: Token, + /// Optional partner fee to apply (`pf_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub partner_fee_id: Option, + /// Optional payer-restriction rules. + #[serde(skip_serializing_if = "Option::is_none")] + pub payer_rules: Option, + /// Whether this quote is for an OTC (over-the-counter) transaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_otc: Option, +} + +impl Default for CreatePayinQuoteInput { + fn default() -> Self { + Self { + blockchain_wallet_id: None, + wallet_id: None, + currency_type: CurrencyType::Sender, + cover_fees: false, + request_amount: 0, + payment_method: PaymentMethod::Pix, + token: Token::Usdc, + partner_fee_id: None, + payer_rules: None, + is_otc: None, + } + } +} + +/// A payin quote, returned by `PayinQuotes::create`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct PayinQuote { + /// Quote id. + pub id: String, + /// Epoch (seconds) after which the quote expires. + pub expires_at: i64, + /// Commercial quotation (e.g. `4.95` = 1 USD = 4.95 BRL). + pub commercial_quotation: f64, + /// BlindPay quotation (commercial quotation plus BlindPay fees). + pub blindpay_quotation: f64, + /// Amount the receiver gets, in cents. + pub receiver_amount: i64, + /// Amount the sender must send, in cents. + pub sender_amount: i64, + /// Partner fee amount in cents, if any. + #[serde(default)] + pub partner_fee_amount: Option, + /// Flat fee in cents. + pub flat_fee: i64, + /// Billing fee in cents, if any. + #[serde(default)] + pub billing_fee_amount: Option, + /// Whether the quote is for an OTC transaction. + #[serde(default)] + pub is_otc: Option, +} + +/// Input for `PayinQuotes::get_fx_rate` +/// (`POST /instances/{instance_id}/payin-quotes/fx`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PayinQuoteFxInput { + /// Source currency. + pub from: Currency, + /// Target stablecoin token. + pub to: Token, + /// Requested amount in cents. + pub request_amount: i64, + /// Which side the `request_amount` is denominated in. + pub currency_type: CurrencyType, +} + +/// An FX-rate preview for a payin, returned by `PayinQuotes::get_fx_rate`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct PayinQuoteFx { + /// Commercial quotation. + pub commercial_quotation: f64, + /// BlindPay quotation. + pub blindpay_quotation: f64, + /// Resulting amount in cents. + pub result_amount: i64, + /// Instance flat fee in cents. + pub instance_flat_fee: i64, + /// Instance percentage fee in basis points. + pub instance_percentage_fee: i64, +} + +/// Input for `Payins::create_evm` (`POST /instances/{instance_id}/payins/evm`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreatePayinInput { + /// The payin quote to execute (`pq_…`). + pub payin_quote_id: String, +} + +/// PSE payment instruction embedded in a payin's transaction tracking. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PseInstruction { + /// PSE payment link URL. + pub payment_link: String, + /// PSE funding identifier (for webhook matching). + pub fid: String, + /// PSE payer full name. + pub full_name: String, + /// PSE payer document number. + pub tax_id: String, + /// PSE payer document type (`CC` or `NIT`). + pub document_type: PseDocumentType, + /// PSE payer phone number. + pub phone: String, + /// PSE payer email. + pub email: String, + /// PSE payer bank code. + #[serde(default)] + pub bank_code: Option, +} + +/// Transfers 3.0 payment instruction embedded in a payin's transaction tracking. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct TransfersInstruction { + /// CVU/CBU/Alias account identifier. + pub account: String, + /// Account type (`CVU`, `CBU`, or `ALIAS`). + #[serde(rename = "type")] + pub transfers_type: TransfersType, + /// Payer CUIT/CUIL (digits only), if known. + #[serde(default)] + pub tax_id: Option, +} + +/// TED payment instruction embedded in a payin's transaction tracking. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct TedInstruction { + /// Beneficiary name. + pub beneficiary_name: String, + /// Beneficiary tax id. + pub tax_id: String, + /// Bank code. + pub bank_code: String, + /// Bank name. + pub bank_name: String, + /// Branch code. + pub branch_code: String, + /// Account number. + pub account_number: String, + /// Account type. + pub account_type: String, +} + +/// The transaction step of a payin (inbound fiat leg). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayinTrackingTransaction { + /// The tracking step. + pub step: TrackingStatus, + /// Sub-status, when applicable. + #[serde(default)] + pub status: Option, + /// External transaction id. + #[serde(default)] + pub external_id: Option, + /// Completion timestamp. + #[serde(default)] + pub completed_at: Option, + /// Blockchain transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// Payment provider name. + #[serde(default)] + pub provider_name: Option, + /// Payment provider transaction id. + #[serde(default)] + pub provider_transaction_id: Option, + /// Sender name. + #[serde(default)] + pub sender_name: Option, + /// Sender bank name. + #[serde(default)] + pub sender_bank_name: Option, + /// Sender tax id. + #[serde(default)] + pub sender_tax_id: Option, + /// Sender bank code. + #[serde(default)] + pub sender_bank_code: Option, + /// Sender account number. + #[serde(default)] + pub sender_account_number: Option, + /// BACEN PIX end-to-end transaction id. + #[serde(default)] + pub end_to_end_id: Option, + /// ACH trace number. + #[serde(default)] + pub trace_number: Option, + /// Transaction reference. + #[serde(default)] + pub transaction_reference: Option, + /// Free-text description. + #[serde(default)] + pub description: Option, + /// PSE payment instruction, when the rail is PSE. + #[serde(default)] + pub pse_instruction: Option, + /// Transfers 3.0 payment instruction, when the rail is Transfers. + #[serde(default)] + pub transfers_instruction: Option, + /// TED payment instruction, when the rail is TED. + #[serde(default)] + pub ted_instruction: Option, +} + +/// The payment step of a payin (BlindPay processing leg). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayinTrackingPayment { + /// The tracking step. + pub step: TrackingStatus, + /// Payment provider name. + #[serde(default)] + pub provider_name: Option, + /// Completion timestamp. + #[serde(default)] + pub completed_at: Option, +} + +/// The completion step of a payin (on-chain settlement leg). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayinTrackingComplete { + /// The tracking step. + pub step: TrackingStatus, + /// Blockchain transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// Completion timestamp. + #[serde(default)] + pub completed_at: Option, +} + +/// The partner-fee settlement step of a payin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayinTrackingPartnerFee { + /// The tracking step. + pub step: TrackingStatus, + /// Blockchain transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// Completion timestamp. + #[serde(default)] + pub completed_at: Option, +} + +/// BlindPay deposit instructions returned for a payin (where the sender pays). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BlindpayBankAccount { + /// Routing number. + pub routing_number: String, + /// Account number. + pub account_number: String, +} + +/// A beneficiary or receiving-bank block within [`BlindpayBankDetails`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BlindpayBankParty { + /// Party name. + pub name: String, + /// First address line. + pub address_line_1: String, + /// Second address line. + pub address_line_2: String, +} + +/// SWIFT receiving-bank block (nullable fields) within [`BlindpayBankDetails`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct SwiftReceivingBank { + /// Bank name. + #[serde(default)] + pub name: Option, + /// First address line. + #[serde(default)] + pub address_line_1: Option, + /// Second address line. + #[serde(default)] + pub address_line_2: Option, +} + +/// BlindPay bank details a sender uses to fund a payin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BlindpayBankDetails { + /// Routing number. + pub routing_number: String, + /// Account number. + pub account_number: String, + /// Account type label. + pub account_type: String, + /// SWIFT/BIC code. + #[serde(default)] + pub swift_bic_code: Option, + /// ACH account block. + #[serde(default)] + pub ach: Option, + /// Wire account block. + #[serde(default)] + pub wire: Option, + /// RTP account block. + #[serde(default)] + pub rtp: Option, + /// Beneficiary block. + pub beneficiary: BlindpayBankParty, + /// Receiving-bank block. + pub receiving_bank: BlindpayBankParty, + /// SWIFT account number. + #[serde(default)] + pub swift_account_number: Option, + /// SWIFT receiving bank. + #[serde(default)] + pub swift_receiving_bank: Option, +} + +/// A payin, returned by `Payins::get`, `Payins::list`, and `Payins::get_track`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct Payin { + /// Payin id (`pi_…`). + pub id: String, + /// The receiver/customer this payin belongs to (`re_…`). + pub receiver_id: String, + /// PIX copy-and-paste code, when the rail is PIX. + #[serde(default)] + pub pix_code: Option, + /// Memo code, when applicable. + #[serde(default)] + pub memo_code: Option, + /// CLABE, when the rail is SPEI. + #[serde(default)] + pub clabe: Option, + /// Overall payin status. + pub status: TransactionStatus, + /// The quote this payin executed (`pq_…`). + pub payin_quote_id: String, + /// Owning instance id (`in_…`). + pub instance_id: String, + /// Inbound fiat-leg tracking. + pub tracking_transaction: PayinTrackingTransaction, + /// Processing-leg tracking. + pub tracking_payment: PayinTrackingPayment, + /// Settlement-leg tracking. + pub tracking_complete: PayinTrackingComplete, + /// Partner-fee settlement tracking, when applicable. + #[serde(default)] + pub tracking_partner_fee: Option, + /// Creation timestamp. + pub created_at: String, + /// Last-update timestamp. + pub updated_at: String, + /// Receiver avatar URL. + #[serde(default)] + pub image_url: Option, + /// Receiver first name (individual). + #[serde(default)] + pub first_name: Option, + /// Receiver last name (individual). + #[serde(default)] + pub last_name: Option, + /// Receiver legal name (business). + #[serde(default)] + pub legal_name: Option, + /// Whether the receiver is an individual or a business. + #[serde(rename = "type")] + pub account_class: AccountClass, + /// Payment method of the underlying quote. + pub payment_method: PaymentMethod, + /// Amount the sender sends, in cents. + pub sender_amount: i64, + /// Amount the receiver gets, in cents. + pub receiver_amount: i64, + /// Stablecoin token received. + pub token: Token, + /// Applied partner fee id (`pf_…`), if any. + #[serde(default)] + pub partner_fee_id: Option, + /// Partner fee amount in cents, if any. + #[serde(default)] + pub partner_fee_amount: Option, + /// Total fee amount in cents, if any. + #[serde(default)] + pub total_fee_amount: Option, + /// Commercial quotation. + pub commercial_quotation: f64, + /// BlindPay quotation. + pub blindpay_quotation: f64, + /// Quote currency. + pub currency: Currency, + /// Billing fee amount in cents, if any. + #[serde(default)] + pub billing_fee_amount: Option, + /// Transaction fee amount in cents, if any. + #[serde(default)] + pub transaction_fee_amount: Option, + /// Whether the underlying quote is OTC. + #[serde(default)] + pub is_otc: Option, + /// Payer rules applied to the quote, if any. + #[serde(default)] + pub payer_rules: Option, + // `name` and `network` stay optional: `get_track` decodes this same type from + // the unauthenticated `/e/` endpoint, which can null these fields. + /// Receiver display name. + #[serde(default)] + pub name: Option, + /// Destination wallet address. + #[serde(default)] + pub address: Option, + /// Destination wallet network. + #[serde(default)] + pub network: Option, + /// BlindPay deposit instructions, when the sender funds the payin. + #[serde(default)] + pub blindpay_bank_details: Option, + /// PSE payment link. + #[serde(default)] + pub pse_payment_link: Option, + /// PSE payer full name. + #[serde(default)] + pub pse_full_name: Option, + /// PSE payer document number. + #[serde(default)] + pub pse_tax_id: Option, + /// PSE payer document type (`CC` or `NIT`). + #[serde(default)] + pub pse_document_type: Option, +} + +/// The response of `Payins::create_evm` +/// (`POST /instances/{instance_id}/payins/evm`). +/// +/// A narrower projection of a payin than [`Payin`], carrying the deposit +/// instructions and tracking needed right after creation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreatePayinResponse { + /// Payin id (`pi_…`). + pub id: String, + /// Overall payin status. + pub status: TransactionStatus, + /// PIX copy-and-paste code, when the rail is PIX. + #[serde(default)] + pub pix_code: Option, + /// Memo code, when applicable. + #[serde(default)] + pub memo_code: Option, + /// CLABE, when the rail is SPEI. + #[serde(default)] + pub clabe: Option, + /// Partner fee amount in cents. + #[serde(default)] + pub partner_fee: Option, + /// Settlement-leg tracking. + pub tracking_complete: PayinTrackingComplete, + /// Processing-leg tracking. + pub tracking_payment: PayinTrackingPayment, + /// Inbound fiat-leg tracking. + pub tracking_transaction: PayinTrackingTransaction, + /// Partner-fee settlement tracking, when applicable. + #[serde(default)] + pub tracking_partner_fee: Option, + /// Billing fee amount in cents, if any. + #[serde(default)] + pub billing_fee_amount: Option, + /// Transaction fee amount in cents, if any. + #[serde(default)] + pub transaction_fee_amount: Option, + /// BlindPay deposit instructions. + pub blindpay_bank_details: BlindpayBankDetails, + /// The receiver/customer this payin belongs to (`re_…`). + #[serde(default)] + pub receiver_id: Option, + /// Amount the receiver gets, in cents. + #[serde(default)] + pub receiver_amount: Option, + /// Payment method of the underlying quote. + #[serde(default)] + pub payment_method: Option, + /// Amount the sender sends, in cents. + #[serde(default)] + pub sender_amount: Option, +} + +/// Query parameters for `Payins::list` (`GET /instances/{instance_id}/payins`). +/// +/// Combines [`PaginationParams`](crate::common::PaginationParams)-style cursors +/// with payin-specific filters; only set fields are sent on the wire. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ListPayinsParams { + /// Maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Number of items to skip. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Cursor: return items after this object id. + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_after: Option, + /// Cursor: return items before this object id. + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_before: Option, + /// Filter by payin status. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Filter by customer id (`re_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_id: Option, + /// Filter by receiver display name. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_name: Option, + /// Filter by bank account id (`ba_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bank_account_id: Option, + /// Filter by country. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + /// Filter by payment method. + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_method: Option, + /// Filter by destination network. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + /// Filter by token. + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl ListPayinsParams { + /// Creates an empty set of list parameters. + pub fn new() -> Self { + Self::default() + } + + /// Sets the maximum number of items to return. + pub fn limit(mut self, limit: crate::common::Limit) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the number of items to skip. + pub fn offset(mut self, offset: crate::common::Offset) -> Self { + self.offset = Some(offset); + self + } + + /// Filters by payin status. + pub fn status(mut self, status: TransactionStatus) -> Self { + self.status = Some(status); + self + } + + /// Filters by customer id. + pub fn customer_id(mut self, customer_id: impl Into) -> Self { + self.customer_id = Some(customer_id.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_payin_quote_input_skips_unset_optional_fields() { + let input = CreatePayinQuoteInput { + blockchain_wallet_id: Some("bw_123".to_string()), + currency_type: CurrencyType::Sender, + cover_fees: true, + request_amount: 1000, + payment_method: PaymentMethod::Pix, + token: Token::Usdc, + ..Default::default() + }; + let json = serde_json::to_value(&input).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "blockchain_wallet_id": "bw_123", + "currency_type": "sender", + "cover_fees": true, + "request_amount": 1000, + "payment_method": "pix", + "token": "USDC" + }) + ); + } + + #[test] + fn payer_rules_round_trips_only_set_fields() { + let rules = PayerRules { + pix_allowed_tax_ids: Some(vec!["123.456.789-09".to_string()]), + ..Default::default() + }; + let json = serde_json::to_value(&rules).unwrap(); + assert_eq!( + json, + serde_json::json!({ "pix_allowed_tax_ids": ["123.456.789-09"] }) + ); + } + + #[test] + fn list_params_serialize_pagination_and_filters() { + let params = ListPayinsParams::new() + .limit(crate::common::Limit::Fifty) + .status(TransactionStatus::Completed) + .customer_id("re_123"); + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "limit": "50", + "status": "completed", + "customer_id": "re_123" + }) + ); + } + + #[test] + fn transfers_type_serde_round_trips() { + for (variant, wire) in [ + (TransfersType::Cvu, "CVU"), + (TransfersType::Cbu, "CBU"), + (TransfersType::Alias, "ALIAS"), + ] { + assert_eq!(serde_json::to_value(&variant).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + variant + ); + } + assert_eq!( + serde_json::from_value::(json!("PVU")).unwrap(), + TransfersType::Unknown("PVU".to_string()) + ); + } + + #[test] + fn pse_document_type_serde_round_trips() { + for (variant, wire) in [(PseDocumentType::Cc, "CC"), (PseDocumentType::Nit, "NIT")] { + assert_eq!(serde_json::to_value(&variant).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + variant + ); + } + assert_eq!( + serde_json::from_value::(json!("CE")).unwrap(), + PseDocumentType::Unknown("CE".to_string()) + ); + } + + #[test] + fn tracking_status_serde_round_trips() { + for (variant, wire) in [ + (TrackingStatus::Processing, "processing"), + (TrackingStatus::OnHold, "on_hold"), + (TrackingStatus::PendingReview, "pending_review"), + (TrackingStatus::Completed, "completed"), + ] { + assert_eq!(serde_json::to_value(&variant).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + variant + ); + } + assert_eq!( + serde_json::from_value::(json!("refunded")).unwrap(), + TrackingStatus::Unknown("refunded".to_string()) + ); + } + + #[test] + fn payin_transaction_status_serde_round_trips() { + for (variant, wire) in [ + (PayinTransactionStatus::Failed, "failed"), + (PayinTransactionStatus::Completed, "completed"), + ] { + assert_eq!(serde_json::to_value(&variant).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + variant + ); + } + assert_eq!( + serde_json::from_value::(json!("pending")).unwrap(), + PayinTransactionStatus::Unknown("pending".to_string()) + ); + } + + #[test] + fn provider_name_serde_round_trips() { + let provider: ProviderName = serde_json::from_value(json!("HSBC")).unwrap(); + assert_eq!(provider, ProviderName::from("HSBC")); + assert_eq!(provider.as_str(), "HSBC"); + assert_eq!(serde_json::to_value(&provider).unwrap(), json!("HSBC")); + } + + #[test] + fn payin_deserializes_with_type_renamed_to_account_class() { + let payin: Payin = serde_json::from_value(serde_json::json!({ + "id": "pi_123", + "receiver_id": "re_123", + "status": "processing", + "payin_quote_id": "pq_123", + "instance_id": "in_test", + "tracking_transaction": { "step": "processing", "external_id": null }, + "tracking_payment": { "step": "processing" }, + "tracking_complete": { "step": "processing" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "individual", + "payment_method": "pix", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "BRL" + })) + .unwrap(); + assert_eq!(payin.id, "pi_123"); + assert_eq!(payin.account_class, AccountClass::Individual); + assert_eq!(payin.payment_method, PaymentMethod::Pix); + assert_eq!(payin.token, Token::Usdc); + assert!(payin.partner_fee_id.is_none()); + } +} diff --git a/src/resources/payouts/mod.rs b/src/resources/payouts/mod.rs new file mode 100644 index 0000000..d90ac0e --- /dev/null +++ b/src/resources/payouts/mod.rs @@ -0,0 +1,142 @@ +//! The `payouts` resource: outbound (stablecoin → fiat) payments. +//! +//! Payouts have no generic `create`: they are chain-specific +//! ([`create_evm`](Payouts::create_evm), [`create_solana`](Payouts::create_solana), +//! [`create_stellar`](Payouts::create_stellar)) and each consumes a `quote_id` +//! produced by the `quotes` resource. + +mod types; + +pub use types::{ + AuthorizeStellarTokenInput, AuthorizeStellarTokenResponse, CreateEvmPayoutInput, + CreatePayoutResponse, CreateSolanaPayoutInput, CreateStellarPayoutInput, + EstimatedTimeOfArrival, ListPayoutsParams, Payout, PayoutCompleteStatus, PayoutDocumentsStatus, + PayoutLiquidityProviderStatus, PayoutPaymentProviderStatus, PayoutTrackingComplete, + PayoutTrackingDocuments, PayoutTrackingLiquidity, PayoutTrackingPartnerFee, + PayoutTrackingPayment, PayoutTrackingStep, PayoutTrackingTransaction, PayoutTransactionStatus, + SubmitPayoutDocumentsInput, SubmitPayoutDocumentsResponse, TransactionDocumentType, +}; + +use std::sync::Arc; + +use crate::client::Inner; +use crate::common::ListResponse; +use crate::error::Result; +use crate::internal::encode_path_segment; + +/// Handle for the `payouts` resource. +/// +/// Obtained from the `payouts` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Payouts { + client: Arc, +} + +impl Payouts { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Creates a payout on an EVM chain from a previously created quote. + /// + /// `POST /instances/{instance_id}/payouts/evm` + pub async fn create_evm(&self, input: &CreateEvmPayoutInput) -> Result { + let path = format!("/instances/{}/payouts/evm", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Creates a payout on Solana from a previously created quote. + /// + /// `POST /instances/{instance_id}/payouts/solana` + pub async fn create_solana( + &self, + input: &CreateSolanaPayoutInput, + ) -> Result { + let path = format!("/instances/{}/payouts/solana", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Creates a payout on Stellar from a previously created quote. + /// + /// `POST /instances/{instance_id}/payouts/stellar` + pub async fn create_stellar( + &self, + input: &CreateStellarPayoutInput, + ) -> Result { + let path = format!("/instances/{}/payouts/stellar", self.client.instance_id); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Authorizes the Stellar token (trustline) for a payout. + /// + /// `POST /instances/{instance_id}/payouts/stellar/authorize` + pub async fn authorize_stellar_token( + &self, + input: &AuthorizeStellarTokenInput, + ) -> Result { + let path = format!( + "/instances/{}/payouts/stellar/authorize", + self.client.instance_id + ); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Submits supporting documents for a payout (e.g. for SWIFT compliance). + /// + /// `POST /instances/{instance_id}/payouts/{id}/documents` + pub async fn submit_documents( + &self, + payout_id: impl AsRef, + input: &SubmitPayoutDocumentsInput, + ) -> Result { + let path = format!( + "/instances/{}/payouts/{}/documents", + self.client.instance_id, + encode_path_segment(payout_id.as_ref()) + ); + self.client + .request::<_, (), _>(reqwest::Method::POST, &path, None, Some(input)) + .await + } + + /// Lists payouts for the instance. + /// + /// `GET /instances/{instance_id}/payouts` + pub async fn list(&self, params: &ListPayoutsParams) -> Result> { + let path = format!("/instances/{}/payouts", self.client.instance_id); + self.client.get_query(&path, params).await + } + + /// Retrieves a single payout by id. + /// + /// `GET /instances/{instance_id}/payouts/{id}` + pub async fn get(&self, payout_id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/payouts/{}", + self.client.instance_id, + encode_path_segment(payout_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Retrieves a payout via its public, unauthenticated tracking endpoint. + /// + /// `GET /e/payouts/{id}` + /// + /// This `/e/` route is the end-customer tracking endpoint: it is served + /// without instance scoping and is not gated by the API key (the SDK still + /// sends the bearer header). Use [`get`](Payouts::get) for the + /// authenticated, instance-scoped lookup. + pub async fn get_track(&self, payout_id: impl AsRef) -> Result { + let path = format!("/e/payouts/{}", encode_path_segment(payout_id.as_ref())); + self.client.get(&path).await + } +} diff --git a/src/resources/payouts/types.rs b/src/resources/payouts/types.rs new file mode 100644 index 0000000..75e6072 --- /dev/null +++ b/src/resources/payouts/types.rs @@ -0,0 +1,966 @@ +//! Request and response types for the `payouts` resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{ + AccountClass, AccountType, Country, Currency, Network, ProviderName, Rail, Token, + TransactionStatus, open_enum, +}; + +open_enum! { + /// The step a payout tracking stage has reached. + /// + /// Distinct from the payout's overall [`TransactionStatus`]: this is the + /// per-stage progression reported inside the `tracking_*` objects. + pub enum PayoutTrackingStep { + /// Stage is being processed. + Processing => "processing", + /// Stage is temporarily on hold. + OnHold => "on_hold", + /// Stage is awaiting manual review. + PendingReview => "pending_review", + /// Stage completed. + Completed => "completed", + } +} + +open_enum! { + /// Estimated time of arrival reported in payout tracking stages. + pub enum EstimatedTimeOfArrival { + /// Within 5 minutes. + FiveMin => "5_min", + /// Between 5 and 30 minutes. + FiveToThirtyMin => "5_30_min", + /// Within 30 minutes. + ThirtyMin => "30_min", + /// Within 2 hours. + TwoHours => "2_hours", + /// Within 1 business day. + OneBusinessDay => "1_business_day", + /// Within 2 business days. + TwoBusinessDays => "2_business_days", + /// Within 5 business days. + FiveBusinessDays => "5_business_days", + } +} + +open_enum! { + /// Transaction-level status reported inside `tracking_transaction`. + pub enum PayoutTransactionStatus { + /// The on-chain transaction failed. + Failed => "failed", + /// The on-chain transaction was found. + Found => "found", + } +} + +open_enum! { + /// Provider status reported inside `tracking_payment`. + pub enum PayoutPaymentProviderStatus { + /// The payment was canceled. + Canceled => "canceled", + /// The payment failed. + Failed => "failed", + /// The payment was returned. + Returned => "returned", + /// The payment was sent. + Sent => "sent", + } +} + +open_enum! { + /// Provider status reported inside `tracking_liquidity`. + pub enum PayoutLiquidityProviderStatus { + /// Funds were deposited. + Deposited => "deposited", + /// Funds were converted. + Converted => "converted", + /// Funds were withdrawn. + Withdrawn => "withdrawn", + } +} + +open_enum! { + /// Completion status reported inside `tracking_complete`. + pub enum PayoutCompleteStatus { + /// Tokens were refunded to the sender. + TokensRefunded => "tokens_refunded", + /// The payout was paid out. + Paid => "paid", + } +} + +open_enum! { + /// Documents status reported inside `tracking_documents`. + pub enum PayoutDocumentsStatus { + /// Awaiting supporting documents from the sender. + WaitingDocuments => "waiting_documents", + /// Documents are under compliance review. + ComplianceReviewing => "compliance_reviewing", + } +} + +open_enum! { + /// The type of a transaction supporting document. + pub enum TransactionDocumentType { + /// An invoice. + Invoice => "invoice", + /// A purchase order. + PurchaseOrder => "purchase_order", + /// A delivery slip. + DeliverySlip => "delivery_slip", + /// A contract. + Contract => "contract", + /// A customs declaration. + CustomsDeclaration => "customs_declaration", + /// A bill of lading. + BillOfLading => "bill_of_lading", + /// Any other document type. + Others => "others", + } +} + +/// The on-chain (token) leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingTransaction { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Transaction-level status (`failed` / `found`), if known. + #[serde(default)] + pub status: Option, + /// Blockchain transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, + /// Provider-side handle for the on-chain leg (e.g. Circle CPN payment id). + #[serde(default)] + pub provider_transaction_id: Option, + /// Ledger transaction id for the inbound (crypto deposit) leg. + #[serde(default)] + pub ledger_in_transaction_id: Option, + /// Ledger transaction id for the outbound (fiat withdrawal) leg. + #[serde(default)] + pub ledger_out_transaction_id: Option, +} + +/// The fiat-payment leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingPayment { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Payment provider name. + #[serde(default)] + pub provider_name: Option, + /// Payment provider transaction id. + #[serde(default)] + pub provider_transaction_id: Option, + /// Payment provider status (`canceled` / `failed` / `returned` / `sent`). + #[serde(default)] + pub provider_status: Option, + /// Provider error reason when the payment fails. + #[serde(default)] + pub provider_error_reason: Option, + /// Unique end-to-end transaction reference (UETR). + #[serde(default)] + pub provider_uetr: Option, + /// Fed Input Message Accountability Data (IMAD); domestic Fedwire only. + #[serde(default)] + pub provider_imad: Option, + /// Clearing system that processed the payment (e.g. `FED`, `CHIPS`). + #[serde(default)] + pub provider_clearing_system: Option, + /// Recipient name. + #[serde(default)] + pub recipient_name: Option, + /// Recipient tax id. + #[serde(default)] + pub recipient_tax_id: Option, + /// Recipient bank code. + #[serde(default)] + pub recipient_bank_code: Option, + /// Recipient branch code. + #[serde(default)] + pub recipient_branch_code: Option, + /// Recipient account number. + #[serde(default)] + pub recipient_account_number: Option, + /// Recipient account type. + #[serde(default)] + pub recipient_account_type: Option, + /// COELSA transaction reference id for ARS transfers. + #[serde(default)] + pub coelsa_id: Option, + /// BACEN Pix end-to-end transaction id. + #[serde(default)] + pub end_to_end_id: Option, + /// Estimated time of arrival. + #[serde(default)] + pub estimated_time_of_arrival: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, +} + +/// The liquidity-provisioning leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingLiquidity { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Payment provider transaction id. + #[serde(default)] + pub provider_transaction_id: Option, + /// Provider status (`deposited` / `converted` / `withdrawn`). + #[serde(default)] + pub provider_status: Option, + /// Estimated time of arrival. + #[serde(default)] + pub estimated_time_of_arrival: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, +} + +/// The completion leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingComplete { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Completion status (`tokens_refunded` / `paid`). + #[serde(default)] + pub status: Option, + /// Reason for refund when tokens are returned to the sender. + #[serde(default)] + pub refund_reason: Option, + /// Completion transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, +} + +/// The partner-fee settlement leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingPartnerFee { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Partner-fee transaction hash. + #[serde(default)] + pub transaction_hash: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, +} + +/// The supporting-documents leg of a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PayoutTrackingDocuments { + /// The step this stage has reached. + pub step: PayoutTrackingStep, + /// Documents status (`waiting_documents` / `compliance_reviewing`). + #[serde(default)] + pub status: Option, + /// Email or name of the reviewer. + #[serde(default)] + pub reviewed_by: Option, + /// When the stage completed (ISO 8601). + #[serde(default)] + pub completed_at: Option, +} + +/// A payout — the large joined object returned by `Payouts::get`, +/// `Payouts::get_track`, and each item of `Payouts::list`. +/// +/// Combines the payout record with denormalized receiver, quote, and +/// bank-account fields. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Payout { + /// Receiver id (`re_…`). + pub receiver_id: String, + /// Payout id (`pa_…`). + pub id: String, + /// Overall payout status. + pub status: TransactionStatus, + /// Sender wallet address. + pub sender_wallet_address: String, + /// Signed transaction, for chains that require client-side signing. + #[serde(default)] + pub signed_transaction: Option, + /// Quote id this payout was created from (`qu_…`). + pub quote_id: String, + /// Instance id (`in_…`). + pub instance_id: String, + /// Partner fee amount in cents (can be negative for rebates). + #[serde(default)] + pub partner_fee: Option, + /// On-chain (token) tracking leg. + #[serde(default)] + pub tracking_transaction: Option, + /// Fiat-payment tracking leg. + #[serde(default)] + pub tracking_payment: Option, + /// Liquidity tracking leg. + #[serde(default)] + pub tracking_liquidity: Option, + /// Completion tracking leg. + #[serde(default)] + pub tracking_complete: Option, + /// Partner-fee tracking leg. + #[serde(default)] + pub tracking_partner_fee: Option, + /// Documents tracking leg. + #[serde(default)] + pub tracking_documents: Option, + /// JPMorgan tracking data, when the payout was processed via JPM. + #[serde(default)] + pub jpm_track_data: Option, + /// Circle CPN payment id. + #[serde(default)] + pub cpn_payment_id: Option, + /// Creation timestamp (ISO 8601). + #[serde(default)] + pub created_at: Option, + /// Last-update timestamp (ISO 8601). + #[serde(default)] + pub updated_at: Option, + + // ---- Denormalized receiver fields ---- + /// Receiver image URL. + #[serde(default)] + pub image_url: Option, + /// Receiver first name. + #[serde(default)] + pub first_name: Option, + /// Receiver last name. + #[serde(default)] + pub last_name: Option, + /// Receiver legal (business) name. + #[serde(default)] + pub legal_name: Option, + + // ---- Denormalized quote fields ---- + /// Destination network. + #[serde(default)] + pub network: Option, + /// Stablecoin token. + #[serde(default)] + pub token: Option, + /// Payment description / memo. + #[serde(default)] + pub description: Option, + /// Amount the sender sends, in cents. + #[serde(default)] + pub sender_amount: Option, + /// Amount the receiver receives (in stablecoin), in cents. + #[serde(default)] + pub receiver_amount: Option, + /// Partner fee id (`pf_…`). + #[serde(default)] + pub partner_fee_id: Option, + /// Partner fee amount in cents. + #[serde(default)] + pub partner_fee_amount: Option, + /// Commercial quotation. + #[serde(default)] + pub commercial_quotation: Option, + /// BlindPay quotation. + #[serde(default)] + pub blindpay_quotation: Option, + /// Total fee amount in cents. + #[serde(default)] + pub total_fee_amount: Option, + /// Amount the receiver receives in their local currency, in cents. + #[serde(default)] + pub receiver_local_amount: Option, + /// Local fiat currency of the receiver. + #[serde(default)] + pub currency: Option, + /// Transaction fee amount in cents. + #[serde(default)] + pub transaction_fee_amount: Option, + /// Billing fee amount in cents. + #[serde(default)] + pub billing_fee_amount: Option, + /// URL of the supporting transaction document. + #[serde(default)] + pub transaction_document_file: Option, + /// Type of the supporting transaction document. + #[serde(default)] + pub transaction_document_type: Option, + /// Identifier of the supporting transaction document. + #[serde(default)] + pub transaction_document_id: Option, + + // ---- Denormalized bank-account fields ---- + /// Bank account holder name. + #[serde(default)] + pub name: Option, + /// Bank account rail. + #[serde(default, rename = "type")] + pub account_rail: Option, + /// PIX key. + #[serde(default)] + pub pix_key: Option, + /// PIX Safe bank code. + #[serde(default)] + pub pix_safe_bank_code: Option, + /// PIX Safe branch code. + #[serde(default)] + pub pix_safe_branch_code: Option, + /// PIX Safe CPF/CNPJ. + #[serde(default)] + pub pix_safe_cpf_cnpj: Option, + /// TED bank code. + #[serde(default)] + pub ted_bank_code: Option, + /// TED branch code. + #[serde(default)] + pub ted_branch_code: Option, + /// TED CPF/CNPJ. + #[serde(default)] + pub ted_cpf_cnpj: Option, + /// Account number. + #[serde(default)] + pub account_number: Option, + /// Routing number. + #[serde(default)] + pub routing_number: Option, + /// Bank account country. + #[serde(default)] + pub country: Option, + /// Account class (individual / business). + #[serde(default)] + pub account_class: Option, + /// Address line 1. + #[serde(default)] + pub address_line_1: Option, + /// Address line 2. + #[serde(default)] + pub address_line_2: Option, + /// City. + #[serde(default)] + pub city: Option, + /// State / province / region. + #[serde(default)] + pub state_province_region: Option, + /// Postal code. + #[serde(default)] + pub postal_code: Option, + /// Bank account type (checking / saving). + #[serde(default)] + pub account_type: Option, + /// ACH COP beneficiary first name. + #[serde(default)] + pub ach_cop_beneficiary_first_name: Option, + /// ACH COP bank account. + #[serde(default)] + pub ach_cop_bank_account: Option, + /// ACH COP bank code. + #[serde(default)] + pub ach_cop_bank_code: Option, + /// ACH COP beneficiary last name. + #[serde(default)] + pub ach_cop_beneficiary_last_name: Option, + /// ACH COP document id. + #[serde(default)] + pub ach_cop_document_id: Option, + /// ACH COP document type. + #[serde(default)] + pub ach_cop_document_type: Option, + /// ACH COP email. + #[serde(default)] + pub ach_cop_email: Option, + /// Beneficiary name. + #[serde(default)] + pub beneficiary_name: Option, + /// SPEI CLABE. + #[serde(default)] + pub spei_clabe: Option, + /// SPEI protocol. + #[serde(default)] + pub spei_protocol: Option, + /// SPEI institution code. + #[serde(default)] + pub spei_institution_code: Option, + /// SWIFT beneficiary country. + #[serde(default)] + pub swift_beneficiary_country: Option, + /// SWIFT/BIC code. + #[serde(default)] + pub swift_code_bic: Option, + /// SWIFT account holder name. + #[serde(default)] + pub swift_account_holder_name: Option, + /// SWIFT account number / IBAN. + #[serde(default)] + pub swift_account_number_iban: Option, + /// Transfers 3.0 account. + #[serde(default)] + pub transfers_account: Option, + /// Transfers 3.0 account type (`CVU` / `CBU` / `ALIAS`). + #[serde(default)] + pub transfers_type: Option, + + /// Whether the payout is associated with a virtual account. + #[serde(default)] + pub has_virtual_account: Option, + /// Legal name of the sending instance (originator), shown on the receipt. + #[serde(default)] + pub sender_legal_name: Option, +} + +/// The response returned by the chain-specific create methods +/// (`create_evm`, `create_solana`, `create_stellar`). +/// +/// A trimmed view of a payout: the just-created record plus its initial +/// tracking stages and resolved relation ids. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreatePayoutResponse { + /// Payout id (`pa_…`). + pub id: String, + /// Overall payout status. + pub status: TransactionStatus, + /// Sender wallet address. + pub sender_wallet_address: String, + /// Billing fee amount in cents. + #[serde(default)] + pub billing_fee_amount: Option, + /// Transaction fee amount in cents. + #[serde(default)] + pub transaction_fee_amount: Option, + /// Partner fee amount in cents (can be negative for rebates). + #[serde(default)] + pub partner_fee: Option, + /// Completion tracking leg. + #[serde(default)] + pub tracking_complete: Option, + /// Fiat-payment tracking leg. + #[serde(default)] + pub tracking_payment: Option, + /// On-chain (token) tracking leg. + #[serde(default)] + pub tracking_transaction: Option, + /// Partner-fee tracking leg. + #[serde(default)] + pub tracking_partner_fee: Option, + /// Liquidity tracking leg. + #[serde(default)] + pub tracking_liquidity: Option, + /// Documents tracking leg. + #[serde(default)] + pub tracking_documents: Option, + /// Receiver id (`re_…`). + #[serde(default)] + pub receiver_id: Option, + /// Bank account id (`ba_…`). + #[serde(default)] + pub bank_account_id: Option, + /// Offramp wallet id (`ow_…`). + #[serde(default)] + pub offramp_wallet_id: Option, +} + +/// Input for `Payouts::create_evm` +/// (`POST /instances/{instance_id}/payouts/evm`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateEvmPayoutInput { + /// Quote id to consume (`qu_…`). + pub quote_id: String, + /// Sender wallet address. + pub sender_wallet_address: String, +} + +impl CreateEvmPayoutInput { + /// Creates the input from a quote id and sender wallet address. + pub fn new(quote_id: impl Into, sender_wallet_address: impl Into) -> Self { + Self { + quote_id: quote_id.into(), + sender_wallet_address: sender_wallet_address.into(), + } + } +} + +/// Input for `Payouts::create_solana` +/// (`POST /instances/{instance_id}/payouts/solana`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateSolanaPayoutInput { + /// Quote id to consume (`qu_…`). + pub quote_id: String, + /// Sender wallet address. + pub sender_wallet_address: String, + /// Client-signed transaction, when the flow requires it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signed_transaction: Option, +} + +impl CreateSolanaPayoutInput { + /// Creates the input from a quote id and sender wallet address. + pub fn new(quote_id: impl Into, sender_wallet_address: impl Into) -> Self { + Self { + quote_id: quote_id.into(), + sender_wallet_address: sender_wallet_address.into(), + signed_transaction: None, + } + } + + /// Sets the signed transaction. + pub fn signed_transaction(mut self, signed_transaction: impl Into) -> Self { + self.signed_transaction = Some(signed_transaction.into()); + self + } +} + +/// Input for `Payouts::create_stellar` +/// (`POST /instances/{instance_id}/payouts/stellar`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateStellarPayoutInput { + /// Quote id to consume (`qu_…`). + pub quote_id: String, + /// Sender wallet address. + pub sender_wallet_address: String, + /// Client-signed transaction, when the flow requires it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signed_transaction: Option, +} + +impl CreateStellarPayoutInput { + /// Creates the input from a quote id and sender wallet address. + pub fn new(quote_id: impl Into, sender_wallet_address: impl Into) -> Self { + Self { + quote_id: quote_id.into(), + sender_wallet_address: sender_wallet_address.into(), + signed_transaction: None, + } + } + + /// Sets the signed transaction. + pub fn signed_transaction(mut self, signed_transaction: impl Into) -> Self { + self.signed_transaction = Some(signed_transaction.into()); + self + } +} + +/// Input for `Payouts::authorize_stellar_token` +/// (`POST /instances/{instance_id}/payouts/stellar/authorize`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizeStellarTokenInput { + /// Quote id to consume (`qu_…`). + pub quote_id: String, + /// Sender wallet address. + pub sender_wallet_address: String, +} + +impl AuthorizeStellarTokenInput { + /// Creates the input from a quote id and sender wallet address. + pub fn new(quote_id: impl Into, sender_wallet_address: impl Into) -> Self { + Self { + quote_id: quote_id.into(), + sender_wallet_address: sender_wallet_address.into(), + } + } +} + +/// Response from `Payouts::authorize_stellar_token`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct AuthorizeStellarTokenResponse { + /// The authorization transaction hash. + pub transaction_hash: String, +} + +/// Input for `Payouts::submit_documents` +/// (`POST /instances/{instance_id}/payouts/{id}/documents`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SubmitPayoutDocumentsInput { + /// Document type. + pub transaction_document_type: TransactionDocumentType, + /// Document identifier (invoice number, contract id, etc.). + pub transaction_document_id: String, + /// URL to the uploaded document. + pub transaction_document_file: String, + /// Optional payment description / memo (max 128 chars). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Response from `Payouts::submit_documents`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct SubmitPayoutDocumentsResponse { + /// Whether the documents were submitted successfully. + pub success: bool, +} + +/// Query parameters for `Payouts::list` +/// (`GET /instances/{instance_id}/payouts`). +/// +/// Combines [`PaginationParams`](crate::common::PaginationParams)-style cursors +/// with payout-specific filters; only set fields are sent on the wire. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ListPayoutsParams { + /// Maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Number of items to skip. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// Cursor: return items after this object id. + #[serde(skip_serializing_if = "Option::is_none")] + pub starting_after: Option, + /// Cursor: return items before this object id. + #[serde(skip_serializing_if = "Option::is_none")] + pub ending_before: Option, + /// Filter by receiver id (`re_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_id: Option, + /// Filter by customer id (`cus_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub customer_id: Option, + /// Filter by payout status. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Filter by receiver display name. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_name: Option, + /// Filter by bank account id (`ba_…`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bank_account_id: Option, + /// Filter by country. + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + /// Filter by payment rail. + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_method: Option, + /// Filter by destination network. + #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + /// Filter by token. + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl ListPayoutsParams { + /// Creates an empty set of list parameters. + pub fn new() -> Self { + Self::default() + } + + /// Sets the maximum number of items to return. + pub fn limit(mut self, limit: crate::common::Limit) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the number of items to skip. + pub fn offset(mut self, offset: crate::common::Offset) -> Self { + self.offset = Some(offset); + self + } + + /// Filters by payout status. + pub fn status(mut self, status: TransactionStatus) -> Self { + self.status = Some(status); + self + } + + /// Filters by receiver id. + pub fn receiver_id(mut self, receiver_id: impl Into) -> Self { + self.receiver_id = Some(receiver_id.into()); + self + } + + /// Filters by payment rail. + pub fn payment_method(mut self, rail: Rail) -> Self { + self.payment_method = Some(rail); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transaction_document_type_round_trips() { + assert_eq!( + serde_json::to_string(&TransactionDocumentType::BillOfLading).unwrap(), + "\"bill_of_lading\"" + ); + assert_eq!( + serde_json::from_str::("\"invoice\"").unwrap(), + TransactionDocumentType::Invoice + ); + assert_eq!( + serde_json::from_str::("\"future\"").unwrap(), + TransactionDocumentType::Unknown("future".to_string()) + ); + } + + #[test] + fn tracking_step_handles_pending_review_and_unknown() { + assert_eq!( + serde_json::from_str::("\"pending_review\"").unwrap(), + PayoutTrackingStep::PendingReview + ); + assert_eq!( + serde_json::from_str::("\"new_step\"").unwrap(), + PayoutTrackingStep::Unknown("new_step".to_string()) + ); + } + + #[test] + fn estimated_time_of_arrival_round_trips() { + assert_eq!( + serde_json::to_string(&EstimatedTimeOfArrival::OneBusinessDay).unwrap(), + "\"1_business_day\"" + ); + assert_eq!( + serde_json::from_str::("\"5_30_min\"").unwrap(), + EstimatedTimeOfArrival::FiveToThirtyMin + ); + } + + #[test] + fn create_solana_input_skips_unset_signed_transaction() { + let input = CreateSolanaPayoutInput::new("qu_123", "0xabc"); + let json = serde_json::to_value(&input).unwrap(); + assert_eq!( + json, + serde_json::json!({ "quote_id": "qu_123", "sender_wallet_address": "0xabc" }) + ); + + let with_sig = CreateSolanaPayoutInput::new("qu_123", "0xabc").signed_transaction("AAA"); + let json = serde_json::to_value(&with_sig).unwrap(); + assert_eq!(json["signed_transaction"], "AAA"); + } + + #[test] + fn submit_documents_input_skips_unset_description() { + let input = SubmitPayoutDocumentsInput { + transaction_document_type: TransactionDocumentType::Invoice, + transaction_document_id: "INV-12345".to_string(), + transaction_document_file: "https://example.com/document.pdf".to_string(), + description: None, + }; + let json = serde_json::to_value(&input).unwrap(); + assert!(json.get("description").is_none()); + assert_eq!(json["transaction_document_type"], "invoice"); + } + + #[test] + fn payout_transaction_status_round_trips() { + assert_eq!( + serde_json::to_string(&PayoutTransactionStatus::Found).unwrap(), + "\"found\"" + ); + assert_eq!( + serde_json::from_str::("\"failed\"").unwrap(), + PayoutTransactionStatus::Failed + ); + assert_eq!( + serde_json::from_str::("\"future\"").unwrap(), + PayoutTransactionStatus::Unknown("future".to_string()) + ); + } + + #[test] + fn payout_payment_provider_status_round_trips() { + assert_eq!( + serde_json::to_string(&PayoutPaymentProviderStatus::Returned).unwrap(), + "\"returned\"" + ); + assert_eq!( + serde_json::from_str::("\"sent\"").unwrap(), + PayoutPaymentProviderStatus::Sent + ); + } + + #[test] + fn payout_liquidity_provider_status_round_trips() { + assert_eq!( + serde_json::to_string(&PayoutLiquidityProviderStatus::Converted).unwrap(), + "\"converted\"" + ); + assert_eq!( + serde_json::from_str::("\"withdrawn\"").unwrap(), + PayoutLiquidityProviderStatus::Withdrawn + ); + } + + #[test] + fn payout_complete_status_round_trips() { + assert_eq!( + serde_json::to_string(&PayoutCompleteStatus::TokensRefunded).unwrap(), + "\"tokens_refunded\"" + ); + assert_eq!( + serde_json::from_str::("\"paid\"").unwrap(), + PayoutCompleteStatus::Paid + ); + } + + #[test] + fn payout_documents_status_round_trips() { + assert_eq!( + serde_json::to_string(&PayoutDocumentsStatus::WaitingDocuments).unwrap(), + "\"waiting_documents\"" + ); + assert_eq!( + serde_json::from_str::("\"compliance_reviewing\"").unwrap(), + PayoutDocumentsStatus::ComplianceReviewing + ); + } + + #[test] + fn provider_name_round_trips() { + // Vendor display names include spaces and mixed casing; the newtype + // preserves them verbatim. + assert_eq!( + serde_json::to_string(&ProviderName::from("JPMorgan Chase")).unwrap(), + "\"JPMorgan Chase\"" + ); + assert_eq!( + serde_json::from_str::("\"Triple A Technologies\"").unwrap(), + ProviderName::from("Triple A Technologies") + ); + } + + #[test] + fn list_params_serializes_network_and_token() { + let params = ListPayoutsParams { + network: Some(Network::Polygon), + token: Some(Token::Usdc), + ..ListPayoutsParams::new() + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["network"], "polygon"); + assert_eq!(json["token"], "USDC"); + } + + #[test] + fn list_params_skip_unset_fields() { + let params = ListPayoutsParams::new() + .limit(crate::common::Limit::Fifty) + .payment_method(Rail::Pix) + .status(TransactionStatus::Completed); + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "limit": "50", + "payment_method": "pix", + "status": "completed" + }) + ); + } +} diff --git a/src/resources/quotes/mod.rs b/src/resources/quotes/mod.rs new file mode 100644 index 0000000..ee568c8 --- /dev/null +++ b/src/resources/quotes/mod.rs @@ -0,0 +1,51 @@ +//! The `quotes` resource: payout quotes and FX-rate lookups. +//! +//! A created [`Quote`]'s `id` is the `quote_id` consumed when creating a payout. + +mod types; + +pub use types::{ + CreateQuoteInput, GetFxRateInput, Quote, QuoteContract, QuoteContractNetwork, QuoteFx, +}; + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::error::Result; + +/// Handle for the `quotes` resource. +/// +/// Obtained from the `quotes` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Quotes { + client: Arc, +} + +impl Quotes { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Creates a payout quote. The returned [`Quote::id`] is used to create a + /// payout. + /// + /// `POST /instances/{instance_id}/quotes` + pub async fn create(&self, input: &CreateQuoteInput) -> Result { + let path = format!("/instances/{}/quotes", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(input)) + .await + } + + /// Returns an FX-rate quote without creating a payout quote. + /// + /// `POST /instances/{instance_id}/quotes/fx` + pub async fn get_fx_rate(&self, input: &GetFxRateInput) -> Result { + let path = format!("/instances/{}/quotes/fx", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(input)) + .await + } +} diff --git a/src/resources/quotes/types.rs b/src/resources/quotes/types.rs new file mode 100644 index 0000000..e31de66 --- /dev/null +++ b/src/resources/quotes/types.rs @@ -0,0 +1,185 @@ +//! Request and response types for the `quotes` (payout quotes) resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{Currency, CurrencyType, Network, Token}; + +/// Body for `Quotes::create` (`POST /instances/{instance_id}/quotes`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateQuoteInput { + /// The bank account that will receive the payout. + pub bank_account_id: String, + /// Which side of the quote `request_amount` is denominated in. + pub currency_type: CurrencyType, + /// If `true`, the sender covers the fees; if `false`, the receiver does. + pub cover_fees: bool, + /// Requested amount in the smallest unit (cents). Minimum `500`. + pub request_amount: i64, + /// The network the payout will be settled on. + pub network: Network, + /// The stablecoin token used for the payout. + pub token: Token, + /// Optional memo/description (only honored for USD and BRL). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional partner fee to apply to the quote. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub partner_fee_id: Option, +} + +/// The blockchain network referenced by a [`QuoteContract`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct QuoteContractNetwork { + /// Human-readable network name, e.g. `"Ethereum"`. + pub name: String, + /// EVM chain id, e.g. `1`. + pub chain_id: i64, +} + +/// The on-chain ERC-20 approval the sender must make to fund a payout. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct QuoteContract { + /// The ERC-20 ABI for the network in the request. + #[serde(default)] + pub abi: Vec, + /// Token contract address for the requested network. + pub address: String, + /// The ERC-20 function to call, e.g. `"approve"`. + pub function_name: String, + /// The BlindPay contract address to approve. + pub blindpay_contract_address: String, + /// Amount to approve, with the token's decimal places applied (string). + pub amount: String, + /// The network the contract call targets. + pub network: QuoteContractNetwork, +} + +/// A payout quote, returned by `Quotes::create`. +/// +/// Its `id` is the `quote_id` consumed when creating a payout. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct Quote { + /// The quote id (prefix `qu_`). + pub id: String, + /// Epoch (seconds) after which the quote is no longer valid. + pub expires_at: i64, + /// Commercial FX quotation, e.g. `4.95` for `1 USD = 4.95 BRL`. + pub commercial_quotation: f64, + /// BlindPay FX quotation (commercial minus BlindPay's spread). + pub blindpay_quotation: f64, + /// Amount the receiver gets, in the smallest unit. + pub receiver_amount: i64, + /// Amount the sender must send, in the smallest unit. + pub sender_amount: i64, + /// Partner fee charged, in the smallest unit. + #[serde(default)] + pub partner_fee_amount: Option, + /// Flat fee charged, in the smallest unit. + #[serde(default)] + pub flat_fee: Option, + /// Billing fee charged via invoice, in the smallest unit. + #[serde(default)] + pub billing_fee_amount: Option, + /// Estimated amount in the receiver bank account's local currency. + #[serde(default)] + pub receiver_local_amount: Option, + /// The memo/description echoed back from the request. + #[serde(default)] + pub description: Option, + /// The on-chain approval the sender must make to fund the payout. + #[serde(default)] + pub contract: Option, +} + +/// Body for `Quotes::get_fx_rate` (`POST /instances/{instance_id}/quotes/fx`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GetFxRateInput { + /// The stablecoin token to convert from. + pub from: Token, + /// The fiat currency to convert to. + pub to: Currency, + /// Requested amount in the smallest unit (cents). Minimum `500`. + pub request_amount: i64, + /// Which side of the quote `request_amount` is denominated in. + pub currency_type: CurrencyType, +} + +/// An FX-rate quote, returned by `Quotes::get_fx_rate`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct QuoteFx { + /// Commercial FX quotation, e.g. `4.95` for `1 USD = 4.95 BRL`. + pub commercial_quotation: f64, + /// BlindPay FX quotation (commercial minus BlindPay's spread). + pub blindpay_quotation: f64, + /// The converted amount, in the smallest unit. + pub result_amount: i64, + /// The instance's flat fee, in the smallest unit. + #[serde(default)] + pub instance_flat_fee: Option, + /// The instance's percentage fee, in basis points. + pub instance_percentage_fee: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_quote_input_skips_optional_fields() { + let input = CreateQuoteInput { + bank_account_id: "ba_123".to_string(), + currency_type: CurrencyType::Sender, + cover_fees: true, + request_amount: 1000, + network: Network::Sepolia, + token: Token::Usdc, + description: None, + partner_fee_id: None, + }; + let value = serde_json::to_value(&input).unwrap(); + assert_eq!(value["request_amount"], 1000); + assert_eq!(value["token"], "USDC"); + assert!(value.get("description").is_none()); + assert!(value.get("partner_fee_id").is_none()); + } + + #[test] + fn quote_contract_uses_camel_case_wire_keys() { + let json = serde_json::json!({ + "abi": [{}], + "address": "0xabc", + "functionName": "approve", + "blindpayContractAddress": "0xdef", + "amount": "1000000000000000000", + "network": { "name": "Ethereum", "chainId": 1 } + }); + let contract: QuoteContract = serde_json::from_value(json).unwrap(); + assert_eq!(contract.function_name, "approve"); + assert_eq!(contract.blindpay_contract_address, "0xdef"); + assert_eq!(contract.network.chain_id, 1); + assert_eq!(contract.abi.len(), 1); + } + + #[test] + fn quote_defaults_absent_optional_fields() { + let json = serde_json::json!({ + "id": "qu_123", + "expires_at": 1712958191_i64, + "commercial_quotation": 4.95, + "blindpay_quotation": 4.85, + "receiver_amount": 5240, + "sender_amount": 1010 + }); + let quote: Quote = serde_json::from_value(json).unwrap(); + assert_eq!(quote.id, "qu_123"); + assert_eq!(quote.partner_fee_amount, None); + assert_eq!(quote.flat_fee, None); + assert!(quote.contract.is_none()); + } +} diff --git a/src/resources/transfers/mod.rs b/src/resources/transfers/mod.rs new file mode 100644 index 0000000..6ff9c9d --- /dev/null +++ b/src/resources/transfers/mod.rs @@ -0,0 +1,93 @@ +//! The `transfers` resource: stablecoin transfers between blockchain wallets, +//! plus the `transfers.quotes` sub-resource for creating transfer quotes. + +mod quotes; +mod types; + +pub use quotes::TransferQuotes; +pub use types::{ + CreateTransferInput, CreateTransferQuoteInput, CreateTransferResponse, Transfer, TransferQuote, + TransferTrackingStep, TransferTrackingTransactionMonitoring, +}; + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::{ListResponse, PaginationParams}; +use crate::error::{Error, Result}; +use crate::internal::encode_path_segment; + +/// Handle for the `transfers` resource. +/// +/// Obtained from the `transfers` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Transfers { + client: Arc, + /// Sub-resource for creating transfer quotes. + pub quotes: TransferQuotes, +} + +impl Transfers { + pub(crate) fn new(client: Arc) -> Self { + Self { + quotes: TransferQuotes::new(Arc::clone(&client)), + client, + } + } + + /// Creates a transfer from a previously created transfer quote. + /// + /// `POST /instances/{instance_id}/transfers` + pub async fn create(&self, input: &CreateTransferInput) -> Result { + let path = format!("/instances/{}/transfers", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(input)) + .await + } + + /// Lists transfers for the instance. Always paginated. + /// + /// `GET /instances/{instance_id}/transfers` + pub async fn list(&self, params: &PaginationParams) -> Result> { + let path = format!("/instances/{}/transfers", self.client.instance_id); + self.client.get_query(&path, params).await + } + + /// Retrieves a single transfer by ID. + /// + /// `GET /instances/{instance_id}/transfers/{id}` + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `id` is empty. + pub async fn get(&self, id: impl AsRef) -> Result { + let id = id.as_ref().trim(); + if id.is_empty() { + return Err(Error::Config("transfer id cannot be empty".to_string())); + } + let path = format!( + "/instances/{}/transfers/{}", + self.client.instance_id, + encode_path_segment(id) + ); + self.client.get(&path).await + } + + /// Retrieves a transfer through the unauthenticated tracking endpoint. + /// + /// `GET /e/transfers/{id}` + /// + /// # Errors + /// + /// Returns [`Error::Config`] if `id` is empty. + pub async fn get_track(&self, id: impl AsRef) -> Result { + let id = id.as_ref().trim(); + if id.is_empty() { + return Err(Error::Config("transfer id cannot be empty".to_string())); + } + let path = format!("/e/transfers/{}", encode_path_segment(id)); + self.client.get(&path).await + } +} diff --git a/src/resources/transfers/quotes.rs b/src/resources/transfers/quotes.rs new file mode 100644 index 0000000..b1f1aac --- /dev/null +++ b/src/resources/transfers/quotes.rs @@ -0,0 +1,33 @@ +//! The `transfers.quotes` sub-resource: transfer-quote creation. + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::error::Result; +use crate::resources::transfers::types::{CreateTransferQuoteInput, TransferQuote}; + +/// Handle for the `transfers.quotes` sub-resource. +/// +/// Obtained from the `quotes` field of a [`Transfers`](super::Transfers) handle. +#[derive(Clone)] +pub struct TransferQuotes { + client: Arc, +} + +impl TransferQuotes { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Creates a transfer quote. + /// + /// `POST /instances/{instance_id}/transfer-quotes` + pub async fn create(&self, input: &CreateTransferQuoteInput) -> Result { + let path = format!("/instances/{}/transfer-quotes", self.client.instance_id); + self.client + .request(Method::POST, &path, None::<&()>, Some(input)) + .await + } +} diff --git a/src/resources/transfers/types.rs b/src/resources/transfers/types.rs new file mode 100644 index 0000000..31b8019 --- /dev/null +++ b/src/resources/transfers/types.rs @@ -0,0 +1,282 @@ +//! Request and response types for the `transfers` resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{CurrencyType, Network, TrackingStatus, TransactionStatus}; + +/// A single blockchain step in a transfer's tracking timeline. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct TransferTrackingStep { + /// The step's status. + pub step: TrackingStatus, + /// On-chain transaction hash, if any. + #[serde(default)] + pub transaction_hash: Option, + /// Gas fee for the transaction, as a string, if any. + #[serde(default)] + pub gas_fee: Option, + /// When the step completed (ISO 8601), if it has. + #[serde(default)] + pub completed_at: Option, + /// Error message for the step, if it failed. + #[serde(default)] + pub error_message: Option, +} + +/// The transaction-monitoring step of a transfer's tracking timeline. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct TransferTrackingTransactionMonitoring { + /// The step's status. + pub step: TrackingStatus, + /// Blockchain screening score (0–100), if computed. + #[serde(default)] + pub blockchain_screening: Option, + /// Risk score (0–100), if computed. + #[serde(default)] + pub risk_score: Option, + /// When the step completed (ISO 8601), if it has. + #[serde(default)] + pub completed_at: Option, +} + +/// A transfer between two blockchain wallets. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Transfer { + /// The transfer identifier. + pub id: String, + /// The transfer's general status. + pub status: TransactionStatus, + /// The quote this transfer was created from. + pub transfer_quote_id: String, + /// The instance this transfer belongs to. + pub instance_id: String, + /// Transaction-monitoring tracking step. + pub tracking_transaction_monitoring: TransferTrackingTransactionMonitoring, + /// Paymaster tracking step. + pub tracking_paymaster: TransferTrackingStep, + /// Bridge-swap tracking step. + pub tracking_bridge_swap: TransferTrackingStep, + /// Completion tracking step. + pub tracking_complete: TransferTrackingStep, + /// Partner-fee tracking step. + pub tracking_partner_fee: TransferTrackingStep, + /// When the transfer was created (ISO 8601). + pub created_at: String, + /// When the transfer was last updated (ISO 8601). + pub updated_at: String, + /// Receiver image URL, if any. + #[serde(default)] + pub image_url: Option, + /// Receiver first name, if any. + #[serde(default)] + pub first_name: Option, + /// Receiver last name, if any. + #[serde(default)] + pub last_name: Option, + /// Receiver legal name, if any. + #[serde(default)] + pub legal_name: Option, + /// The sending wallet's identifier. + pub wallet_id: String, + /// The token sent. + pub sender_token: crate::common::Token, + /// The amount debited from the sender, in the smallest unit (cents). + pub sender_amount: i64, + /// The amount credited to the receiver, in the smallest unit (cents). + pub receiver_amount: i64, + /// The receiver's blockchain network. + pub receiver_network: Network, + /// The token received. + pub receiver_token: crate::common::Token, + /// The receiver's wallet address. + pub receiver_wallet_address: String, + /// The partner fee charged, in the smallest unit (cents), if any. + #[serde(default)] + pub partner_fee_amount: Option, + /// The wallet's external identifier, if any. + #[serde(default)] + pub external_id: Option, + /// The receiver this transfer's wallet belongs to. + pub receiver_id: String, + /// The sending wallet's blockchain address. + pub address: String, + /// The sending wallet's blockchain network. + pub network: Network, +} + +/// Input for `Transfers::create`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateTransferInput { + /// The quote to execute, e.g. `qu_000000000000`. + pub transfer_quote_id: String, +} + +impl CreateTransferInput { + /// Creates the input from a transfer-quote ID. + pub fn new(transfer_quote_id: impl Into) -> Self { + Self { + transfer_quote_id: transfer_quote_id.into(), + } + } +} + +/// Response from `Transfers::create`: the freshly created transfer's id, status, +/// and initial tracking steps. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreateTransferResponse { + /// The transfer identifier. + pub id: String, + /// The transfer's general status. + pub status: TransactionStatus, + /// Bridge-swap tracking step. + pub tracking_bridge_swap: TransferTrackingStep, + /// Completion tracking step. + pub tracking_complete: TransferTrackingStep, + /// Paymaster tracking step. + pub tracking_paymaster: TransferTrackingStep, + /// Transaction-monitoring tracking step. + pub tracking_transaction_monitoring: TransferTrackingTransactionMonitoring, + /// Partner-fee tracking step. + pub tracking_partner_fee: TransferTrackingStep, +} + +/// Input for `TransferQuotes::create`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateTransferQuoteInput { + /// The sending wallet's identifier. + pub wallet_id: String, + /// Whether `request_amount` is denominated in the sender's or receiver's + /// currency. + pub amount_reference: CurrencyType, + /// The requested amount, in the smallest unit (cents). + pub request_amount: i64, + /// The token to send. + pub sender_token: crate::common::Token, + /// The receiver's wallet address. + pub receiver_wallet_address: String, + /// The token to receive. + pub receiver_token: crate::common::Token, + /// The receiver's blockchain network. + pub receiver_network: Network, + /// Whether the sender covers the fees (`true`) or the receiver does + /// (`false`). + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_fees: Option, + /// A partner fee to apply to the quote, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub partner_fee_id: Option, +} + +impl CreateTransferQuoteInput { + /// Creates a quote input with the required fields; optional fields default to + /// unset. + #[allow(clippy::too_many_arguments)] + pub fn new( + wallet_id: impl Into, + amount_reference: CurrencyType, + request_amount: i64, + sender_token: crate::common::Token, + receiver_wallet_address: impl Into, + receiver_token: crate::common::Token, + receiver_network: Network, + ) -> Self { + Self { + wallet_id: wallet_id.into(), + amount_reference, + request_amount, + sender_token, + receiver_wallet_address: receiver_wallet_address.into(), + receiver_token, + receiver_network, + cover_fees: None, + partner_fee_id: None, + } + } + + /// Sets whether the sender covers the fees. + pub fn cover_fees(mut self, cover_fees: bool) -> Self { + self.cover_fees = Some(cover_fees); + self + } + + /// Sets the partner fee to apply. + pub fn partner_fee_id(mut self, partner_fee_id: impl Into) -> Self { + self.partner_fee_id = Some(partner_fee_id.into()); + self + } +} + +/// A transfer quote, returned by `TransferQuotes::create`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct TransferQuote { + /// The quote identifier, e.g. `qu_000000000000`. + pub id: String, + /// Unix epoch (ms) after which the quote is no longer valid, if set. + #[serde(default)] + pub expires_at: Option, + /// The commercial quotation, if set. + #[serde(default)] + pub commercial_quotation: Option, + /// The BlindPay quotation, if set. + #[serde(default)] + pub blindpay_quotation: Option, + /// The amount the receiver gets, in the smallest unit (cents). + pub receiver_amount: i64, + /// The amount the sender pays, in the smallest unit (cents). + pub sender_amount: i64, + /// The partner fee, in the smallest unit (cents), if any. + #[serde(default)] + pub partner_fee_amount: Option, + /// The flat fee, in the smallest unit (cents). + pub flat_fee: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_transfer_input_serializes_quote_id() { + let json = serde_json::to_value(CreateTransferInput::new("qu_000000000000")).unwrap(); + assert_eq!( + json, + serde_json::json!({ "transfer_quote_id": "qu_000000000000" }) + ); + } + + #[test] + fn create_transfer_quote_input_skips_unset_optionals() { + let input = CreateTransferQuoteInput::new( + "bl_000000000000", + CurrencyType::Sender, + 1000, + crate::common::Token::Usdc, + "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + crate::common::Token::Usdc, + Network::Polygon, + ); + let json = serde_json::to_value(&input).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "wallet_id": "bl_000000000000", + "amount_reference": "sender", + "request_amount": 1000, + "sender_token": "USDC", + "receiver_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "receiver_token": "USDC", + "receiver_network": "polygon" + }) + ); + + let with_opts = input.cover_fees(true).partner_fee_id("pf_000000000000"); + let json = serde_json::to_value(&with_opts).unwrap(); + assert_eq!(json["cover_fees"], serde_json::json!(true)); + assert_eq!(json["partner_fee_id"], serde_json::json!("pf_000000000000")); + } +} diff --git a/src/resources/upload/mod.rs b/src/resources/upload/mod.rs new file mode 100644 index 0000000..18ecef4 --- /dev/null +++ b/src/resources/upload/mod.rs @@ -0,0 +1,59 @@ +//! The `upload` resource: a single multipart `POST /upload` endpoint for +//! uploading files (avatars, onboarding documents, limit-increase documents) to +//! BlindPay storage. +//! +//! Unlike the other resources, `/upload` is instance-agnostic: the path carries +//! no instance segment and the instance is passed as the `instance_id` query +//! parameter (matching node/go/cli). The bound client instance id is sent +//! automatically. + +mod types; + +pub use types::{UploadBucket, UploadResponse}; + +use std::sync::Arc; + +use reqwest::multipart::{Form, Part}; + +use crate::client::Inner; +use crate::error::Result; + +/// Handle for the `upload` resource. +/// +/// Obtained from the `upload` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Upload { + client: Arc, +} + +impl Upload { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Uploads a file to the given storage `bucket` and returns its public URL. + /// + /// The file is sent as multipart `file` + `bucket` form fields; the client's + /// instance id is sent as the `instance_id` query parameter. + /// + /// `POST /upload?instance_id={instance_id}` + pub async fn upload( + &self, + file_name: impl Into, + file: impl Into>, + bucket: UploadBucket, + ) -> Result { + let part = Part::bytes(file.into()).file_name(file_name.into()); + let form = Form::new() + .part("file", part) + .text("bucket", bucket.as_str().to_string()); + + self.client + .post_multipart( + "/upload", + &[("instance_id", &self.client.instance_id)], + form, + ) + .await + } +} diff --git a/src/resources/upload/types.rs b/src/resources/upload/types.rs new file mode 100644 index 0000000..db6726b --- /dev/null +++ b/src/resources/upload/types.rs @@ -0,0 +1,41 @@ +//! Request and response types for the `upload` resource. + +use crate::common::open_enum; + +open_enum! { + /// The storage bucket a file is uploaded into. + pub enum UploadBucket { + /// Profile / instance avatar images. + Avatar => "avatar", + /// Onboarding (KYC/KYB) supporting documents. + Onboarding => "onboarding", + /// Limit-increase supporting documents. + LimitIncrease => "limit_increase", + } +} + +/// Response from `Upload::upload`: the URL of the stored file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[non_exhaustive] +pub struct UploadResponse { + /// Public URL of the uploaded file. + pub file_url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_bucket_round_trips() { + for (variant, wire) in [ + (UploadBucket::Avatar, "\"avatar\""), + (UploadBucket::Onboarding, "\"onboarding\""), + (UploadBucket::LimitIncrease, "\"limit_increase\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), variant); + } + assert_eq!(UploadBucket::LimitIncrease.as_str(), "limit_increase"); + } +} diff --git a/src/resources/virtual_accounts/mod.rs b/src/resources/virtual_accounts/mod.rs new file mode 100644 index 0000000..9b39874 --- /dev/null +++ b/src/resources/virtual_accounts/mod.rs @@ -0,0 +1,104 @@ +//! The `virtual_accounts` resource: per-customer virtual bank accounts that +//! convert inbound USD deposits into stablecoin. + +mod types; + +pub use types::{ + BankingPartner, CreateVirtualAccountInput, SoleProprietorDocType, UpdateVirtualAccountInput, + UsAddress, UsBankCoordinates, UsSwiftIntermediaryBank, UsVirtualAccountDetails, VirtualAccount, + VirtualAccountWallet, +}; + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::Result; +use crate::internal::encode_path_segment; + +/// Handle for the `virtual_accounts` resource. +/// +/// Obtained from the `virtual_accounts` field of a [`BlindPay`](crate::BlindPay) +/// client. +#[derive(Clone)] +pub struct VirtualAccounts { + client: Arc, +} + +impl VirtualAccounts { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the virtual accounts for a customer. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/virtual-accounts` + pub async fn list(&self, customer_id: impl AsRef) -> Result> { + let path = format!( + "/instances/{}/customers/{}/virtual-accounts", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client.get(&path).await + } + + /// Retrieves a single virtual account by id. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/virtual-accounts/{id}` + /// + /// Returns `None` when the API responds with JSON `null` (no matching + /// virtual account). + pub async fn get( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result> { + let path = format!( + "/instances/{}/customers/{}/virtual-accounts/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()) + ); + self.client.get(&path).await + } + + /// Creates a virtual account for a customer. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/virtual-accounts` + pub async fn create( + &self, + customer_id: impl AsRef, + input: &CreateVirtualAccountInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/virtual-accounts", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()) + ); + self.client + .request::(Method::POST, &path, None, Some(input)) + .await + } + + /// Updates the token and blockchain wallet of a virtual account. + /// + /// `PUT /instances/{instance_id}/customers/{customer_id}/virtual-accounts/{id}` + pub async fn update( + &self, + customer_id: impl AsRef, + id: impl AsRef, + input: &UpdateVirtualAccountInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/virtual-accounts/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()) + ); + self.client + .request::(Method::PUT, &path, None, Some(input)) + .await + } +} diff --git a/src/resources/virtual_accounts/types.rs b/src/resources/virtual_accounts/types.rs new file mode 100644 index 0000000..49a5397 --- /dev/null +++ b/src/resources/virtual_accounts/types.rs @@ -0,0 +1,292 @@ +//! Request and response types for the `virtual_accounts` resource. + +use serde::{Deserialize, Serialize}; + +use crate::common::{KycStatus, Network, Token, open_enum}; + +open_enum! { + /// The banking partner backing a virtual account. + pub enum BankingPartner { + /// JPMorgan Chase. + Jpmorgan => "jpmorgan", + /// Citi. + Citi => "citi", + /// HSBC. + Hsbc => "hsbc", + /// Cross River / CFSB. + Cfsb => "cfsb", + } +} + +open_enum! { + /// The document type for a sole-proprietor virtual account. + pub enum SoleProprietorDocType { + /// Master service agreement. + MasterServiceAgreement => "master_service_agreement", + /// Salary slip. + SalarySlip => "salary_slip", + /// Bank statement. + BankStatement => "bank_statement", + } +} + +/// US domestic bank coordinates (routing + account number) for one rail of a +/// virtual account. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct UsBankCoordinates { + /// The bank routing number. + pub routing_number: String, + /// The account number. + pub account_number: String, +} + +/// A beneficiary or bank address block within the US banking details. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct UsAddress { + /// Name of the beneficiary or bank. May be absent. + #[serde(default)] + pub name: Option, + /// First address line. May be absent. + #[serde(default)] + pub address_line_1: Option, + /// Second address line. May be absent. + #[serde(default)] + pub address_line_2: Option, +} + +/// SWIFT intermediary bank details within the US banking details. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct UsSwiftIntermediaryBank { + /// Intermediary bank name. May be absent. + #[serde(default)] + pub name: Option, + /// Intermediary bank SWIFT/BIC code. May be absent. + #[serde(default)] + pub swift_code_bic: Option, + /// Intermediary bank routing number. May be absent. + #[serde(default)] + pub routing_number: Option, + /// First address line. May be absent. + #[serde(default)] + pub address_line_1: Option, + /// Second address line. May be absent. + #[serde(default)] + pub address_line_2: Option, +} + +/// The nested US banking-details object on a [`VirtualAccount`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct UsVirtualAccountDetails { + /// ACH coordinates. May be absent. + #[serde(default)] + pub ach: Option, + /// Domestic wire coordinates. + pub wire: UsBankCoordinates, + /// Real-Time Payments coordinates. May be absent. + #[serde(default)] + pub rtp: Option, + /// SWIFT/BIC code for international wires. May be absent. + #[serde(default)] + pub swift_bic_code: Option, + /// SWIFT account number (IBAN-style) for international wires. May be absent. + #[serde(default)] + pub swift_account_number: Option, + /// Account type display label, e.g. `"Personal checking"` or + /// `"Business checking"`. Kept as a free string rather than an enum because + /// the API exposes these as human-readable labels. May be absent. + #[serde(default)] + pub account_type: Option, + /// Beneficiary details. May be absent. + #[serde(default)] + pub beneficiary: Option, + /// Domestic receiving-bank details. May be absent. + #[serde(default)] + pub receiving_bank: Option, + /// SWIFT receiving-bank details. May be absent. + #[serde(default)] + pub swift_receiving_bank: Option, + /// SWIFT intermediary-bank details. May be absent. + #[serde(default)] + pub swift_intermediary_bank: Option, +} + +/// The blockchain wallet a virtual account settles to, as embedded in the +/// virtual-account response. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct VirtualAccountWallet { + /// The wallet's blockchain network. + pub network: Network, + /// The on-chain wallet address. + pub address: String, +} + +/// A virtual account, returned by the `virtual_accounts` endpoints. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct VirtualAccount { + /// The virtual account identifier (`va_…`). + pub id: String, + /// The banking partner backing the account. + pub banking_partner: BankingPartner, + /// The KYC/KYB status of the owning customer. + pub kyc_status: KycStatus, + /// The US banking details for funding the account. + pub us: UsVirtualAccountDetails, + /// The stablecoin token funds are converted to. + pub token: Token, + /// The blockchain wallet identifier (`bw_…`) funds settle to. May be absent. + #[serde(default)] + pub blockchain_wallet_id: Option, + /// The embedded blockchain wallet. Absent or `null` when no wallet is linked. + #[serde(default)] + pub blockchain_wallet: Option, +} + +/// Body for creating a virtual account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateVirtualAccountInput { + /// The banking partner to provision the account with. + pub banking_partner: BankingPartner, + /// The stablecoin token funds are converted to. + pub token: Token, + /// The blockchain wallet identifier (`bw_…`) funds settle to. + pub blockchain_wallet_id: String, + /// Document type for a sole-proprietor account. + #[serde(skip_serializing_if = "Option::is_none")] + pub sole_proprietor_doc_type: Option, + /// URL of the uploaded sole-proprietor document. + #[serde(skip_serializing_if = "Option::is_none")] + pub sole_proprietor_doc_file: Option, +} + +impl CreateVirtualAccountInput { + /// Creates a new input with the required fields; optional fields default to + /// `None`. + pub fn new( + banking_partner: BankingPartner, + token: Token, + blockchain_wallet_id: impl Into, + ) -> Self { + Self { + banking_partner, + token, + blockchain_wallet_id: blockchain_wallet_id.into(), + sole_proprietor_doc_type: None, + sole_proprietor_doc_file: None, + } + } +} + +/// Body for updating a virtual account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UpdateVirtualAccountInput { + /// The stablecoin token funds are converted to. + pub token: Token, + /// The blockchain wallet identifier (`bw_…`) funds settle to. + pub blockchain_wallet_id: String, +} + +impl UpdateVirtualAccountInput { + /// Creates a new update input from the two required fields. + pub fn new(token: Token, blockchain_wallet_id: impl Into) -> Self { + Self { + token, + blockchain_wallet_id: blockchain_wallet_id.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn banking_partner_round_trips() { + assert_eq!( + serde_json::to_string(&BankingPartner::Jpmorgan).unwrap(), + "\"jpmorgan\"" + ); + assert_eq!( + serde_json::from_str::("\"cfsb\"").unwrap(), + BankingPartner::Cfsb + ); + assert_eq!( + serde_json::from_str::("\"new_bank\"").unwrap(), + BankingPartner::Unknown("new_bank".to_string()) + ); + } + + #[test] + fn sole_proprietor_doc_type_round_trips() { + assert_eq!( + serde_json::to_string(&SoleProprietorDocType::MasterServiceAgreement).unwrap(), + "\"master_service_agreement\"" + ); + assert_eq!( + serde_json::from_str::("\"bank_statement\"").unwrap(), + SoleProprietorDocType::BankStatement + ); + } + + #[test] + fn create_input_skips_absent_optional_fields() { + let input = CreateVirtualAccountInput::new(BankingPartner::Jpmorgan, Token::Usdc, "bw_123"); + let value = serde_json::to_value(&input).unwrap(); + assert_eq!(value["banking_partner"], "jpmorgan"); + assert_eq!(value["token"], "USDC"); + assert_eq!(value["blockchain_wallet_id"], "bw_123"); + assert!(value.get("sole_proprietor_doc_type").is_none()); + assert!(value.get("sole_proprietor_doc_file").is_none()); + } + + #[test] + fn virtual_account_parses_full_us_block_and_null_wallet() { + let json = serde_json::json!({ + "id": "va_123", + "banking_partner": "jpmorgan", + "kyc_status": "approved", + "us": { + "ach": { "routing_number": "021000021", "account_number": "111111111" }, + "wire": { "routing_number": "021000021", "account_number": "222222222" }, + "rtp": null, + "swift_bic_code": "TCCLGB3L", + "account_type": "Business checking", + "beneficiary": { + "name": "Test Co", + "address_line_1": "8 The Green", + "address_line_2": "Dover, DE 19901" + }, + "swift_intermediary_bank": { + "name": "JP Morgan Chase NA", + "swift_code_bic": "CHASUS33" + } + }, + "token": "USDC", + "blockchain_wallet_id": "bw_123", + "blockchain_wallet": null + }); + + let va: VirtualAccount = serde_json::from_value(json).unwrap(); + assert_eq!(va.id, "va_123"); + assert_eq!(va.banking_partner, BankingPartner::Jpmorgan); + assert_eq!(va.kyc_status, KycStatus::Approved); + assert_eq!(va.us.wire.account_number, "222222222"); + assert!(va.us.ach.is_some()); + assert!(va.us.rtp.is_none()); + assert_eq!(va.us.swift_bic_code.as_deref(), Some("TCCLGB3L")); + assert_eq!( + va.us + .swift_intermediary_bank + .unwrap() + .swift_code_bic + .as_deref(), + Some("CHASUS33") + ); + assert!(va.blockchain_wallet.is_none()); + } +} diff --git a/src/resources/wallets/blockchain.rs b/src/resources/wallets/blockchain.rs new file mode 100644 index 0000000..4c74fa3 --- /dev/null +++ b/src/resources/wallets/blockchain.rs @@ -0,0 +1,205 @@ +//! The `wallets.blockchain` sub-resource: blockchain wallets registered for a +//! customer. + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::Result; +use crate::internal::encode_path_segment; + +use super::types::{ + BlockchainWallet, BlockchainWalletMessage, CreateAssetTrustlineResponse, + CreateBlockchainWalletWithAddressInput, CreateBlockchainWalletWithHashInput, + MintUsdbSolanaInput, MintUsdbSolanaResponse, MintUsdbStellarInput, + PrepareSolanaDelegationInput, PrepareSolanaDelegationResponse, +}; + +/// Handle for the `wallets.blockchain` sub-resource. +/// +/// Reached via the `blockchain` field of the +/// [`Wallets`](crate::resources::wallets::Wallets) handle. +#[derive(Clone)] +pub struct BlockchainWallets { + client: Arc, +} + +impl BlockchainWallets { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the blockchain wallets registered for a customer. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/blockchain-wallets` + pub async fn list(&self, customer_id: impl AsRef) -> Result> { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client.get(&path).await + } + + /// Returns the message a customer must sign to register a blockchain wallet + /// by signature. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/blockchain-wallets/sign-message` + pub async fn get_wallet_message( + &self, + customer_id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets/sign-message", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client.get(&path).await + } + + /// Retrieves a single blockchain wallet by ID. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/blockchain-wallets/{id}` + pub async fn get( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client.get(&path).await + } + + /// Registers an account-abstraction blockchain wallet by address. + /// + /// Sets `is_account_abstraction: true`. Use this for non-EVM networks + /// (Stellar, Solana, Tron) and for EVM wallets you register by address. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/blockchain-wallets` + pub async fn create_with_address( + &self, + customer_id: impl AsRef, + input: &CreateBlockchainWalletWithAddressInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Registers a blockchain wallet by signature (EVM networks only). + /// + /// Sets `is_account_abstraction: false`. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/blockchain-wallets` + pub async fn create_with_hash( + &self, + customer_id: impl AsRef, + input: &CreateBlockchainWalletWithHashInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Deletes a blockchain wallet. + /// + /// `DELETE /instances/{instance_id}/customers/{customer_id}/blockchain-wallets/{id}` + pub async fn delete( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/blockchain-wallets/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client + .request::<_, (), ()>(Method::DELETE, &path, None, None) + .await + } + + /// Creates a Stellar asset trustline for a wallet address, returning the + /// unsigned transaction envelope (XDR) to sign and submit. + /// + /// `POST /instances/{instance_id}/create-asset-trustline` + pub async fn create_asset_trustline( + &self, + address: impl Into, + ) -> Result { + #[derive(serde::Serialize)] + struct Body { + address: String, + } + let path = format!( + "/instances/{}/create-asset-trustline", + self.client.instance_id + ); + self.client + .request::<_, (), _>( + Method::POST, + &path, + None, + Some(&Body { + address: address.into(), + }), + ) + .await + } + + /// Mints sandbox USDB to a Stellar wallet. + /// + /// `POST /instances/{instance_id}/mint-usdb-stellar` + pub async fn mint_usdb_stellar(&self, input: &MintUsdbStellarInput) -> Result { + let path = format!("/instances/{}/mint-usdb-stellar", self.client.instance_id); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Mints sandbox USDB to a Solana wallet. + /// + /// `POST /instances/{instance_id}/mint-usdb-solana` + pub async fn mint_usdb_solana( + &self, + input: &MintUsdbSolanaInput, + ) -> Result { + let path = format!("/instances/{}/mint-usdb-solana", self.client.instance_id); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Prepares a Solana token-delegation transaction for signing. + /// + /// `POST /instances/{instance_id}/prepare-delegate-solana` + pub async fn prepare_solana_delegation_transaction( + &self, + input: &PrepareSolanaDelegationInput, + ) -> Result { + let path = format!( + "/instances/{}/prepare-delegate-solana", + self.client.instance_id + ); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } +} diff --git a/src/resources/wallets/custodial.rs b/src/resources/wallets/custodial.rs new file mode 100644 index 0000000..89e1771 --- /dev/null +++ b/src/resources/wallets/custodial.rs @@ -0,0 +1,107 @@ +//! The `wallets.custodial` sub-resource: BlindPay-custodied (Circle) wallets +//! for a customer. + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::common::Success; +use crate::error::Result; +use crate::internal::encode_path_segment; + +use super::types::{CreateWalletInput, Wallet, WalletBalance}; + +/// Handle for the `wallets.custodial` sub-resource. +/// +/// Reached via the `custodial` field of the +/// [`Wallets`](crate::resources::wallets::Wallets) handle. +#[derive(Clone)] +pub struct CustodialWallets { + client: Arc, +} + +impl CustodialWallets { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the custodial wallets for a customer. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/wallets` + pub async fn list(&self, customer_id: impl AsRef) -> Result> { + let path = format!( + "/instances/{}/customers/{}/wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client.get(&path).await + } + + /// Retrieves a single custodial wallet by ID. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/wallets/{id}` + pub async fn get(&self, customer_id: impl AsRef, id: impl AsRef) -> Result { + let path = format!( + "/instances/{}/customers/{}/wallets/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client.get(&path).await + } + + /// Retrieves the per-token balance of a custodial wallet. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/wallets/{id}/balance` + pub async fn get_balance( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/wallets/{}/balance", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client.get(&path).await + } + + /// Creates a custodial wallet for a customer. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/wallets` + pub async fn create( + &self, + customer_id: impl AsRef, + input: &CreateWalletInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + ); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Deletes a custodial wallet. + /// + /// `DELETE /instances/{instance_id}/customers/{customer_id}/wallets/{id}` + pub async fn delete( + &self, + customer_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/wallets/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client + .request::<_, (), ()>(Method::DELETE, &path, None, None) + .await + } +} diff --git a/src/resources/wallets/mod.rs b/src/resources/wallets/mod.rs new file mode 100644 index 0000000..e7e43af --- /dev/null +++ b/src/resources/wallets/mod.rs @@ -0,0 +1,50 @@ +//! The `wallets` namespace: a grouping handle exposing three wallet +//! sub-resources — [`blockchain`](Wallets::blockchain) (customer-owned chain +//! wallets), [`custodial`](Wallets::custodial) (BlindPay-custodied Circle +//! wallets), and [`offramp`](Wallets::offramp) (offramp wallets on a bank +//! account). +//! +//! `Wallets` itself has no endpoints; use one of its sub-resource handles. + +mod blockchain; +mod custodial; +mod offramp; +mod types; + +pub use blockchain::BlockchainWallets; +pub use custodial::CustodialWallets; +pub use offramp::OfframpWallets; +pub use types::{ + BlockchainWallet, BlockchainWalletMessage, CreateAssetTrustlineResponse, + CreateBlockchainWalletWithAddressInput, CreateBlockchainWalletWithHashInput, + CreateOfframpWalletInput, CreateWalletInput, MintUsdbSolanaInput, MintUsdbSolanaResponse, + MintUsdbStellarInput, OfframpWallet, PrepareSolanaDelegationInput, + PrepareSolanaDelegationResponse, Wallet, WalletBalance, WalletTokenBalance, +}; + +use std::sync::Arc; + +use crate::client::Inner; + +/// Namespace handle for the `wallets` resources. +/// +/// Obtained from the `wallets` field of a [`BlindPay`](crate::BlindPay) client. +#[derive(Clone)] +pub struct Wallets { + /// Customer-owned blockchain wallets. + pub blockchain: BlockchainWallets, + /// BlindPay-custodied (Circle) wallets. + pub custodial: CustodialWallets, + /// Offramp wallets attached to a customer's bank account. + pub offramp: OfframpWallets, +} + +impl Wallets { + pub(crate) fn new(client: Arc) -> Self { + Self { + blockchain: BlockchainWallets::new(Arc::clone(&client)), + custodial: CustodialWallets::new(Arc::clone(&client)), + offramp: OfframpWallets::new(client), + } + } +} diff --git a/src/resources/wallets/offramp.rs b/src/resources/wallets/offramp.rs new file mode 100644 index 0000000..a951991 --- /dev/null +++ b/src/resources/wallets/offramp.rs @@ -0,0 +1,87 @@ +//! The `wallets.offramp` sub-resource: offramp wallets attached to a customer's +//! bank account. +//! +//! Although offramp wallets live under a bank account on the wire, they are +//! exposed here (under `wallets`) for parity with the other SDKs; every method +//! takes both a `customer_id` and a `bank_account_id`. + +use std::sync::Arc; + +use reqwest::Method; + +use crate::client::Inner; +use crate::error::Result; +use crate::internal::encode_path_segment; + +use super::types::{CreateOfframpWalletInput, OfframpWallet}; + +/// Handle for the `wallets.offramp` sub-resource. +/// +/// Reached via the `offramp` field of the +/// [`Wallets`](crate::resources::wallets::Wallets) handle. +#[derive(Clone)] +pub struct OfframpWallets { + client: Arc, +} + +impl OfframpWallets { + pub(crate) fn new(client: Arc) -> Self { + Self { client } + } + + /// Lists the offramp wallets attached to a bank account. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/bank-accounts/{bank_account_id}/offramp-wallets` + pub async fn list( + &self, + customer_id: impl AsRef, + bank_account_id: impl AsRef, + ) -> Result> { + let path = format!( + "/instances/{}/customers/{}/bank-accounts/{}/offramp-wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(bank_account_id.as_ref()), + ); + self.client.get(&path).await + } + + /// Creates an offramp wallet on a bank account. + /// + /// `POST /instances/{instance_id}/customers/{customer_id}/bank-accounts/{bank_account_id}/offramp-wallets` + pub async fn create( + &self, + customer_id: impl AsRef, + bank_account_id: impl AsRef, + input: &CreateOfframpWalletInput, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/bank-accounts/{}/offramp-wallets", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(bank_account_id.as_ref()), + ); + self.client + .request::<_, (), _>(Method::POST, &path, None, Some(input)) + .await + } + + /// Retrieves a single offramp wallet by ID. + /// + /// `GET /instances/{instance_id}/customers/{customer_id}/bank-accounts/{bank_account_id}/offramp-wallets/{id}` + pub async fn get( + &self, + customer_id: impl AsRef, + bank_account_id: impl AsRef, + id: impl AsRef, + ) -> Result { + let path = format!( + "/instances/{}/customers/{}/bank-accounts/{}/offramp-wallets/{}", + self.client.instance_id, + encode_path_segment(customer_id.as_ref()), + encode_path_segment(bank_account_id.as_ref()), + encode_path_segment(id.as_ref()), + ); + self.client.get(&path).await + } +} diff --git a/src/resources/wallets/types.rs b/src/resources/wallets/types.rs new file mode 100644 index 0000000..9350d1d --- /dev/null +++ b/src/resources/wallets/types.rs @@ -0,0 +1,339 @@ +//! Request and response types for the `wallets` resources (blockchain wallets, +//! custodial wallets, and offramp wallets). + +use serde::{Deserialize, Serialize}; + +use crate::common::{Network, Token}; + +/// Response from `BlockchainWallets::create_asset_trustline`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct CreateAssetTrustlineResponse { + /// The unsigned Stellar transaction envelope (XDR) to sign and submit. + pub xdr: String, +} + +/// Input for `BlockchainWallets::mint_usdb_stellar`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MintUsdbStellarInput { + /// The Stellar wallet address to mint to. + pub address: String, + /// The amount of USDB to mint. + pub amount: String, + /// A signed trustline transaction (XDR) to submit first, when one is needed. + #[serde(rename = "signedXdr", skip_serializing_if = "Option::is_none")] + pub signed_xdr: Option, +} + +/// Input for `BlockchainWallets::mint_usdb_solana`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MintUsdbSolanaInput { + /// The Solana wallet address to mint to. + pub address: String, + /// The amount of USDB to mint. + pub amount: String, +} + +/// Response from `BlockchainWallets::mint_usdb_solana`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct MintUsdbSolanaResponse { + /// Whether the mint succeeded. + pub success: bool, + /// The Solana transaction signature, when successful. + #[serde(default)] + pub signature: Option, + /// The error message, when unsuccessful. + #[serde(default)] + pub error: Option, +} + +/// Input for `BlockchainWallets::prepare_solana_delegation_transaction`. +/// +/// Provide either `quote_id` (recommended) or both `token_address` and +/// `amount`; `owner_address` is always required. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrepareSolanaDelegationInput { + /// The Solana wallet address that owns the tokens. + pub owner_address: String, + /// A quote ID; when set, the token address and amount are taken from it. + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_id: Option, + /// The SPL token mint address. Required if `quote_id` is not provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub token_address: Option, + /// The token amount to delegate. Required if `quote_id` is not provided. + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, +} + +/// Response from `BlockchainWallets::prepare_solana_delegation_transaction`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct PrepareSolanaDelegationResponse { + /// Whether the transaction was prepared successfully. + pub success: bool, + /// The prepared (base64-encoded) Solana transaction, when successful. + #[serde(default)] + pub transaction: Option, +} + +/// A blockchain wallet registered for a customer, returned by the +/// `wallets.blockchain` endpoints. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BlockchainWallet { + /// Wallet identifier (prefix `bw_`). + pub id: String, + /// Display name for the wallet. + pub name: String, + /// The blockchain network the wallet is on. + pub network: Network, + /// The wallet address. Present for account-abstraction registrations. + #[serde(default)] + pub address: Option, + /// The signature transaction hash. Present for signature-based (EVM) + /// registrations. + #[serde(default)] + pub signature_tx_hash: Option, + /// Whether the wallet was registered as an account-abstraction wallet + /// (address-based) rather than via a signature. + pub is_account_abstraction: bool, + /// Identifier of the customer the wallet belongs to (wire field + /// `receiver_id`, prefix `re_`). + pub receiver_id: String, +} + +/// The challenge message a customer signs to register a blockchain wallet by +/// signature, returned by `wallets.blockchain.get_wallet_message`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct BlockchainWalletMessage { + /// The message to sign. + pub message: String, +} + +/// Request body for registering an account-abstraction blockchain wallet by +/// address (`is_account_abstraction: true`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateBlockchainWalletWithAddressInput { + /// Display name for the wallet. + pub name: String, + /// The blockchain network the wallet is on. + pub network: Network, + /// The wallet address. + pub address: String, + /// Always `true` for this constructor. + pub is_account_abstraction: bool, +} + +/// Request body for registering a blockchain wallet by signature +/// (`is_account_abstraction: false`, EVM networks only). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateBlockchainWalletWithHashInput { + /// Display name for the wallet. + pub name: String, + /// The blockchain network the wallet is on (EVM only). + pub network: Network, + /// The transaction hash of the signature that proves wallet ownership. + pub signature_tx_hash: String, + /// Always `false` for this constructor. + pub is_account_abstraction: bool, +} + +/// A BlindPay-custodied (Circle) wallet, returned by the `wallets.custodial` +/// endpoints. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct Wallet { + /// Wallet identifier (prefix `bl_`). + pub id: String, + /// Display name for the wallet. + pub name: String, + /// Caller-supplied external identifier, if any. + #[serde(default)] + pub external_id: Option, + /// The wallet address, once Circle has provisioned it. + #[serde(default)] + pub address: Option, + /// The blockchain network the wallet is on. + pub network: Network, + /// When the wallet was created. + pub created_at: String, +} + +/// Request body for creating a custodial wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateWalletInput { + /// The blockchain network for the wallet. + pub network: Network, + /// Display name for the wallet. + pub name: String, + /// Optional caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, +} + +/// The balance of a single token within a custodial wallet. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct WalletTokenBalance { + /// The token's on-chain address. + pub address: String, + /// Identifier of the balance record. + pub id: String, + /// The token symbol. + pub symbol: Token, + /// The balance amount. This is a fractional token balance (e.g. `12.5` + /// tokens), not a minor-unit integer like the monetary amounts elsewhere in + /// the API — keep it `f64`. + pub amount: f64, +} + +/// The per-token balances of a custodial wallet, returned by +/// `wallets.custodial.get_balance`. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[non_exhaustive] +pub struct WalletBalance { + /// USDC balance. + #[serde(rename = "USDC")] + pub usdc: WalletTokenBalance, + /// USDT balance. + #[serde(rename = "USDT")] + pub usdt: WalletTokenBalance, + /// USDB balance. + #[serde(rename = "USDB")] + pub usdb: WalletTokenBalance, +} + +/// An offramp wallet attached to a bank account, returned by the +/// `wallets.offramp` endpoints. +/// +/// The `create` endpoint returns a subset of these fields; the absent ones +/// (`instance_id`, `receiver_id`, `bank_account_id`, `created_at`, `updated_at`) +/// default to `None`/empty in that case. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct OfframpWallet { + /// Wallet identifier (prefix `ow_`). + pub id: String, + /// Caller-supplied external identifier, if any. + #[serde(default)] + pub external_id: Option, + /// Identifier of the instance the wallet belongs to. Absent on `create`. + #[serde(default)] + pub instance_id: Option, + /// Identifier of the customer the wallet belongs to (wire field + /// `receiver_id`). Absent on `create`. + #[serde(default)] + pub receiver_id: Option, + /// Identifier of the bank account the wallet is attached to. Absent on + /// `create`. + #[serde(default)] + pub bank_account_id: Option, + /// The underlying Circle wallet identifier, if any. + #[serde(default)] + pub circle_wallet_id: Option, + /// The blockchain network the wallet is on. + pub network: Network, + /// The wallet address. + pub address: String, + /// When the wallet was created. Absent on `create`. + #[serde(default)] + pub created_at: Option, + /// When the wallet was last updated. Absent on `create`. + #[serde(default)] + pub updated_at: Option, +} + +/// Request body for creating an offramp wallet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateOfframpWalletInput { + /// Optional caller-supplied external identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// The blockchain network for the wallet. + pub network: Network, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blockchain_wallet_uses_receiver_id_wire_field() { + let json = serde_json::json!({ + "id": "bw_000000000000", + "name": "My Wallet", + "network": "polygon", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "is_account_abstraction": true, + "receiver_id": "re_000000000000" + }); + let wallet: BlockchainWallet = serde_json::from_value(json).unwrap(); + assert_eq!(wallet.id, "bw_000000000000"); + assert_eq!(wallet.network, Network::Polygon); + assert!(wallet.is_account_abstraction); + assert_eq!(wallet.receiver_id, "re_000000000000"); + assert!(wallet.signature_tx_hash.is_none()); + } + + #[test] + fn create_blockchain_wallet_with_hash_serializes_discriminator() { + let input = CreateBlockchainWalletWithHashInput { + name: "Sig Wallet".to_string(), + network: Network::Base, + signature_tx_hash: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359".to_string(), + is_account_abstraction: false, + }; + let json = serde_json::to_value(&input).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "name": "Sig Wallet", + "network": "base", + "signature_tx_hash": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "is_account_abstraction": false + }) + ); + } + + #[test] + fn wallet_balance_maps_uppercase_token_keys() { + let json = serde_json::json!({ + "USDC": { "address": "0xabc", "id": "tok_1", "symbol": "USDC", "amount": 1000.0 }, + "USDT": { "address": "0xdef", "id": "tok_2", "symbol": "USDT", "amount": 0.0 }, + "USDB": { "address": "0xghi", "id": "tok_3", "symbol": "USDB", "amount": 25.5 } + }); + let balance: WalletBalance = serde_json::from_value(json).unwrap(); + assert_eq!(balance.usdc.symbol, Token::Usdc); + assert_eq!(balance.usdc.amount, 1000.0); + assert_eq!(balance.usdb.amount, 25.5); + } + + #[test] + fn offramp_wallet_create_shape_defaults_absent_fields() { + let json = serde_json::json!({ + "id": "ow_000000000000", + "external_id": null, + "circle_wallet_id": null, + "network": "tron", + "address": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb" + }); + let wallet: OfframpWallet = serde_json::from_value(json).unwrap(); + assert_eq!(wallet.id, "ow_000000000000"); + assert_eq!(wallet.network, Network::Tron); + assert!(wallet.bank_account_id.is_none()); + assert!(wallet.created_at.is_none()); + } + + #[test] + fn create_offramp_wallet_skips_unset_external_id() { + let input = CreateOfframpWalletInput { + external_id: None, + network: Network::Base, + }; + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json, serde_json::json!({ "network": "base" })); + } +} diff --git a/src/webhooks.rs b/src/webhooks.rs new file mode 100644 index 0000000..61036f7 --- /dev/null +++ b/src/webhooks.rs @@ -0,0 +1,209 @@ +//! Webhook signature verification. +//! +//! BlindPay signs webhook deliveries with [Svix](https://www.svix.com), the +//! same scheme used by the other BlindPay SDKs. Each delivery carries three +//! headers — an id, a timestamp, and a signature — which together with the raw +//! request body and your endpoint's signing secret (`whsec_…`) prove the +//! payload's authenticity and freshness. +//! +//! Verify a delivery before trusting it: +//! +//! ```no_run +//! # fn handler(secret: &str, id: &str, ts: &str, body: &[u8], sig: &str) +//! # -> Result<(), blindpay::WebhookVerificationError> { +//! blindpay::verify_webhook_signature(secret, id, ts, body, sig)?; +//! // signature is valid — safe to parse `body` +//! # Ok(()) +//! # } +//! ``` +//! +//! The header names accepted by the underlying Svix verifier are the +//! `Svix-Id` / `Svix-Timestamp` / `Svix-Signature` triplet and their +//! Standard-Webhooks aliases `Webhook-Id` / `Webhook-Timestamp` / +//! `Webhook-Signature`. Pass the values, not the header names — this function +//! builds the header map for you. + +use std::str::FromStr; + +use svix::webhooks::Webhook; + +/// An error returned by [`verify_webhook_signature`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WebhookVerificationError { + /// The signing secret could not be parsed (not a valid `whsec_…` value). + #[error("invalid webhook signing secret")] + InvalidSecret, + + /// One of the supplied header values is not a valid HTTP header value + /// (e.g. contains control characters). + #[error("invalid value for webhook header `{0}`")] + InvalidHeaderValue(&'static str), + + /// The signature did not match, the timestamp was outside the tolerance + /// window, or a required header was missing. + #[error("webhook signature verification failed: {0}")] + Verification(String), +} + +/// Verifies a Svix webhook signature against the raw request body. +/// +/// `secret` is the endpoint's signing secret (`whsec_…`); `id`, `timestamp`, +/// and `signature` are the values of the `Svix-Id` / `Svix-Timestamp` / +/// `Svix-Signature` headers (the `Webhook-*` aliases work too); `payload` is +/// the **raw** request body bytes, exactly as received — re-serializing parsed +/// JSON will change the bytes and break verification. +/// +/// Returns `Ok(())` when the signature is valid and the timestamp is within +/// the Svix tolerance window (±5 minutes). +/// +/// # Errors +/// +/// - [`WebhookVerificationError::InvalidSecret`] if `secret` is malformed. +/// - [`WebhookVerificationError::InvalidHeaderValue`] if a header value cannot +/// be represented as an HTTP header. +/// - [`WebhookVerificationError::Verification`] if the signature is invalid, +/// the timestamp is stale, or a header is missing. +pub fn verify_webhook_signature( + secret: &str, + id: &str, + timestamp: &str, + payload: &[u8], + signature: &str, +) -> Result<(), WebhookVerificationError> { + let webhook = Webhook::new(secret).map_err(|_| WebhookVerificationError::InvalidSecret)?; + + let mut headers = http::HeaderMap::new(); + insert_header(&mut headers, "svix-id", id)?; + insert_header(&mut headers, "svix-timestamp", timestamp)?; + insert_header(&mut headers, "svix-signature", signature)?; + + webhook + .verify(payload, &headers) + .map_err(|err| WebhookVerificationError::Verification(err.to_string())) +} + +fn insert_header( + headers: &mut http::HeaderMap, + name: &'static str, + value: &str, +) -> Result<(), WebhookVerificationError> { + let header_name = http::HeaderName::from_str(name) + .map_err(|_| WebhookVerificationError::InvalidHeaderValue(name))?; + let header_value = http::HeaderValue::from_str(value) + .map_err(|_| WebhookVerificationError::InvalidHeaderValue(name))?; + headers.insert(header_name, header_value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD"; + const MSG_ID: &str = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk"; + const PAYLOAD: &[u8] = br#"{"email":"test@example.com","username":"test_user"}"#; + + fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap() + } + + fn sign(timestamp: i64) -> String { + Webhook::new(SECRET) + .unwrap() + .sign(MSG_ID, timestamp, PAYLOAD) + .unwrap() + } + + #[test] + fn accepts_a_valid_signature() { + let ts = now(); + let signature = sign(ts); + + verify_webhook_signature(SECRET, MSG_ID, &ts.to_string(), PAYLOAD, &signature).unwrap(); + } + + #[test] + fn accepts_the_webhook_alias_secret_without_prefix() { + // `Webhook::new` strips the `whsec_` prefix, so a bare secret verifies too. + let ts = now(); + let signature = sign(ts); + let bare = SECRET.strip_prefix("whsec_").unwrap(); + + verify_webhook_signature(bare, MSG_ID, &ts.to_string(), PAYLOAD, &signature).unwrap(); + } + + #[test] + fn rejects_a_tampered_signature() { + let ts = now(); + // Signature for a different payload — must not validate against PAYLOAD. + let signature = Webhook::new(SECRET) + .unwrap() + .sign(MSG_ID, ts, br#"{"email":"attacker@example.com"}"#) + .unwrap(); + + let err = verify_webhook_signature(SECRET, MSG_ID, &ts.to_string(), PAYLOAD, &signature) + .unwrap_err(); + assert!(matches!(err, WebhookVerificationError::Verification(_))); + } + + #[test] + fn rejects_a_payload_mutated_after_signing() { + let ts = now(); + let signature = sign(ts); + + let err = verify_webhook_signature( + SECRET, + MSG_ID, + &ts.to_string(), + br#"{"email":"test@example.com","username":"changed"}"#, + &signature, + ) + .unwrap_err(); + assert!(matches!(err, WebhookVerificationError::Verification(_))); + } + + #[test] + fn rejects_a_stale_timestamp() { + // Outside the ±5 minute tolerance window. + let ts = now() - 60 * 60; + let signature = sign(ts); + + let err = verify_webhook_signature(SECRET, MSG_ID, &ts.to_string(), PAYLOAD, &signature) + .unwrap_err(); + assert!(matches!(err, WebhookVerificationError::Verification(_))); + } + + #[test] + fn rejects_a_malformed_secret() { + let ts = now(); + let signature = sign(ts); + + // Not valid base64 after the prefix. + let err = verify_webhook_signature( + "whsec_not!!base64", + MSG_ID, + &ts.to_string(), + PAYLOAD, + &signature, + ) + .unwrap_err(); + assert!(matches!(err, WebhookVerificationError::InvalidSecret)); + } + + #[test] + fn rejects_a_header_value_with_control_characters() { + let ts = now(); + let signature = sign(ts); + + let err = verify_webhook_signature(SECRET, "bad\nid", &ts.to_string(), PAYLOAD, &signature) + .unwrap_err(); + assert!(matches!( + err, + WebhookVerificationError::InvalidHeaderValue("svix-id") + )); + } +} diff --git a/tests/available.rs b/tests/available.rs new file mode 100644 index 0000000..1af6926 --- /dev/null +++ b/tests/available.rs @@ -0,0 +1,364 @@ +//! Integration tests for the `available` resource, backed by a mock HTTP server. + +use blindpay::{BlindPay, Error, Rail, RequiredWhenOperator}; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn get_rails_parses_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "label": "Domestic Wire", "value": "wire", "country": "US" }, + { "label": "PIX", "value": "pix", "country": "BR" }, + { "label": "SEPA", "value": "sepa", "country": "DE" }, + ]))) + .mount(&server) + .await; + + let rails = client(&server).await.available.get_rails().await.unwrap(); + + assert_eq!(rails.len(), 3); + assert_eq!(rails[0].label, "Domestic Wire"); + assert_eq!(rails[0].value, Rail::Wire); + assert_eq!(rails[0].country, "US"); + assert_eq!(rails[2].value, Rail::Sepa); +} + +#[tokio::test] +async fn unknown_rail_value_does_not_break_decoding() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "label": "Future Rail", "value": "future_rail", "country": "US" } + ]))) + .mount(&server) + .await; + + let rails = client(&server).await.available.get_rails().await.unwrap(); + assert_eq!(rails[0].value, Rail::Unknown("future_rail".to_string())); + assert_eq!(rails[0].value.as_str(), "future_rail"); +} + +#[tokio::test] +async fn get_bank_details_sends_rail_query_and_parses_optional_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/bank-details")) + .and(query_param("rail", "pix")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "label": "PIX Key", + "regex": "", + "key": "pix_key", + "required": true + }, + { + "label": "Account Type", + "regex": "", + "key": "account_type", + "items": [ + { "label": "Checking", "value": "checking" }, + { "label": "Savings", "value": "saving", "is_active": false } + ], + "required": null, + "requiredWhen": { + "field": "country", + "operator": "in", + "values": ["US", "GB"] + } + } + ]))) + .mount(&server) + .await; + + let details = client(&server) + .await + .available + .get_bank_details(Rail::Pix) + .await + .unwrap(); + + assert_eq!(details.len(), 2); + + // First field: simple, required, no items, no conditional rule. + assert_eq!(details[0].key, "pix_key"); + assert_eq!(details[0].required, Some(true)); + assert!(details[0].items.is_empty()); + assert!(details[0].required_when.is_none()); + + // Second field: `required: null`, items present, conditional rule present. + assert_eq!(details[1].required, None); + assert_eq!(details[1].items.len(), 2); + assert_eq!(details[1].items[1].value, "saving"); + assert_eq!(details[1].items[1].is_active, Some(false)); + + let rule = details[1].required_when.as_ref().unwrap(); + assert_eq!(rule.field, "country"); + assert_eq!(rule.operator, RequiredWhenOperator::In); + assert_eq!(rule.values, vec!["US".to_string(), "GB".to_string()]); +} + +#[tokio::test] +async fn get_naics_codes_uses_label_and_value() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/naics")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { "label": "(339910) Jewelry and Silverware Manufacturing", "value": "339910" } + ]))) + .mount(&server) + .await; + + let codes = client(&server) + .await + .available + .get_naics_codes() + .await + .unwrap(); + + assert_eq!(codes.len(), 1); + assert_eq!(codes[0].value, "339910"); + assert!(codes[0].label.contains("Jewelry")); +} + +#[tokio::test] +async fn get_swift_code_bank_details_parses_camel_case_keys() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/swift/BOFAUS3NLMA")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "416", + "bank": "BANK OF AMERICA, N.A.", + "city": "NEW JERSEY", + "branch": "LENDING SERVICES AND OPERATIONS (LSOP)", + "swiftCode": "BOFAUS3NLMA", + "swiftCodeLink": "https://bank.codes/swift-code/united-states/bofaus3nlma/", + "country": "United States", + "countrySlug": "united-states" + } + ]))) + .mount(&server) + .await; + + let results = client(&server) + .await + .available + .get_swift_code_bank_details("BOFAUS3NLMA") + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "416"); + assert_eq!(results[0].swift_code, "BOFAUS3NLMA"); + assert_eq!(results[0].country_slug, "united-states"); +} + +#[tokio::test] +async fn non_success_status_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "User not allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_rails() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "User not allowed"); + assert!(api.raw_body.contains("User not allowed")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn malformed_body_returns_decode_error_with_raw_body() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_rails() + .await + .unwrap_err(); + + match err { + Error::Decode { body, .. } => assert_eq!(body, "not json"), + other => panic!("expected Error::Decode, got {other:?}"), + } +} + +#[tokio::test] +async fn empty_body_decodes_to_empty_vec() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let rails = client(&server).await.available.get_rails().await.unwrap(); + assert!(rails.is_empty()); +} + +#[tokio::test] +async fn empty_api_key_is_a_config_error() { + let err = BlindPay::new("", "in_test").unwrap_err(); + assert!(matches!(err, Error::Config(_))); +} + +#[tokio::test] +async fn empty_swift_code_fails_before_any_request() { + let client = BlindPay::new("test-api-key", "in_test").unwrap(); + let err = client + .available + .get_swift_code_bank_details(" ") + .await + .unwrap_err(); + assert!(matches!(err, Error::Config(_))); +} + +#[tokio::test] +async fn error_body_without_message_synthesizes_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/rails")) + .respond_with( + ResponseTemplate::new(500).set_body_json(serde_json::json!({ "code": "oops" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_rails() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 500); + // No `message` field in the body, so the message is synthesized... + assert!(api.message.starts_with("HTTP 500 error")); + // ...but the raw body is always retained. + assert!(api.raw_body.contains("oops")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn bank_details_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/bank-details")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "invalid rail" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_bank_details(Rail::Pix) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid rail"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn naics_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/naics")) + .respond_with( + ResponseTemplate::new(503) + .set_body_json(serde_json::json!({ "message": "unavailable" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_naics_codes() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 503); + assert_eq!(api.message, "unavailable"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn swift_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/available/swift/BOFAUS3NLMA")) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({ "message": "not found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .available + .get_swift_code_bank_details("BOFAUS3NLMA") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/customers.rs b/tests/customers.rs new file mode 100644 index 0000000..3d21397 --- /dev/null +++ b/tests/customers.rs @@ -0,0 +1,1057 @@ +//! Integration tests for the `customers` resource (and its `bank_accounts` +//! sub-resource), backed by a mock HTTP server. + +use blindpay::{ + AccountClass, BankAccountStatus, BlindPay, CreateAchInput, CreateBusinessWithStandardKybInput, + CreateIndividualWithStandardKycInput, CreateInternationalSwiftInput, CreatePixInput, Error, + KycStatus, KycType, ListBankAccountsParams, ListCustomersParams, Rail, RecipientRelationship, + RequestLimitIncreaseInput, SupportingDocumentType, UpdateCustomerInput, +}; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn list_customers_parses_paginated_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers")) + .and(query_param("limit", "50")) + .and(query_param("country", "BR")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [ + { + "id": "re_123", + "type": "individual", + "kyc_type": "standard", + "kyc_status": "verifying", + "email": "ana@example.com", + "country": "BR", + "first_name": "Ana", + "last_name": "Silva", + "limit": { "per_transaction": 100000, "daily": 200000, "monthly": 1000000 } + } + ], + "pagination": { "has_more": false, "next_page": null, "prev_page": null } + }))) + .mount(&server) + .await; + + let params = ListCustomersParams::new() + .limit(blindpay::Limit::Fifty) + .country("BR"); + let resp = client(&server).await.customers.list(¶ms).await.unwrap(); + + assert_eq!(resp.data().len(), 1); + let customer = &resp.data()[0]; + assert_eq!(customer.id, "re_123"); + assert_eq!(customer.type_, AccountClass::Individual); + assert_eq!(customer.kyc_type, KycType::Standard); + assert_eq!(customer.kyc_status, KycStatus::Verifying); + assert_eq!(customer.first_name.as_deref(), Some("Ana")); + assert_eq!(customer.limit.as_ref().unwrap().monthly, 1000000); + assert!(resp.pagination().is_some()); + assert!(!resp.pagination().unwrap().has_more); +} + +#[tokio::test] +async fn list_customers_parses_bare_array_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "re_123", + "type": "business", + "kyc_type": "standard", + "kyc_status": "approved", + "legal_name": "Acme LLC" + } + ]))) + .mount(&server) + .await; + + let resp = client(&server) + .await + .customers + .list(&ListCustomersParams::new()) + .await + .unwrap(); + + assert_eq!(resp.data().len(), 1); + assert_eq!(resp.data()[0].type_, AccountClass::Business); + assert_eq!(resp.data()[0].legal_name.as_deref(), Some("Acme LLC")); + assert!(resp.pagination().is_none()); +} + +#[tokio::test] +async fn create_individual_with_standard_kyc_injects_discriminators() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers")) + .and(body_json(serde_json::json!({ + "type": "individual", + "kyc_type": "standard", + "country": "BR", + "email": "ana@example.com", + "first_name": "Ana" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "re_123", + "customer_id": "re_123" + }))) + .mount(&server) + .await; + + let input = CreateIndividualWithStandardKycInput { + country: blindpay::Country::from("BR"), + email: "ana@example.com".to_string(), + first_name: Some("Ana".to_string()), + ..Default::default() + }; + let resp = client(&server) + .await + .customers + .create_individual_with_standard_kyc(&input) + .await + .unwrap(); + + assert_eq!(resp.id, "re_123"); + assert_eq!(resp.customer_id, "re_123"); +} + +#[tokio::test] +async fn create_business_with_standard_kyb_injects_business_type() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers")) + .and(body_json(serde_json::json!({ + "type": "business", + "kyc_type": "standard", + "country": "US", + "email": "ops@acme.com", + "legal_name": "Acme LLC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "re_456", + "customer_id": "re_456" + }))) + .mount(&server) + .await; + + let input = CreateBusinessWithStandardKybInput { + country: blindpay::Country::from("US"), + email: "ops@acme.com".to_string(), + legal_name: Some("Acme LLC".to_string()), + ..Default::default() + }; + let resp = client(&server) + .await + .customers + .create_business_with_standard_kyb(&input) + .await + .unwrap(); + + assert_eq!(resp.id, "re_456"); +} + +#[tokio::test] +async fn get_customer_parses_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers/re_123")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "re_123", + "type": "individual", + "kyc_type": "enhanced", + "kyc_status": "approved", + "email": "ana@example.com", + "aml_status": "clear", + "aml_hits": { + "has_sanction_match": false, + "has_pep_match": false, + "has_watchlist_match": false, + "has_crimelist_match": false, + "has_adversemedia_match": false + } + }))) + .mount(&server) + .await; + + let customer = client(&server).await.customers.get("re_123").await.unwrap(); + assert_eq!(customer.kyc_type, KycType::Enhanced); + assert_eq!(customer.kyc_status, KycStatus::Approved); + assert_eq!(customer.aml_status.as_deref(), Some("clear")); + assert!(!customer.aml_hits.as_ref().unwrap().has_pep_match); +} + +#[tokio::test] +async fn update_customer_sends_put_and_returns_success() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/instances/in_test/customers/re_123")) + .and(body_json(serde_json::json!({ "email": "new@example.com" }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let input = UpdateCustomerInput { + email: Some("new@example.com".to_string()), + ..Default::default() + }; + let resp = client(&server) + .await + .customers + .update("re_123", &input) + .await + .unwrap(); + assert!(resp.success); +} + +#[tokio::test] +async fn delete_customer_returns_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test/customers/re_123")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let resp = client(&server) + .await + .customers + .delete("re_123") + .await + .unwrap(); + assert!(resp.success); +} + +#[tokio::test] +async fn get_limits_parses_payin_and_payout() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/limits/customers/re_123")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "limits": { + "payin": { "daily": 10000, "monthly": 50000 }, + "payout": { "daily": 20000, "monthly": 100000 } + } + }))) + .mount(&server) + .await; + + let limits = client(&server) + .await + .customers + .get_limits("re_123") + .await + .unwrap(); + assert_eq!(limits.limits.payin.daily, 10000); + assert_eq!(limits.limits.payout.monthly, 100000); +} + +#[tokio::test] +async fn get_limit_increase_requests_parses_array() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/re_123/limit-increase", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "rl_123", + "receiver_id": "re_123", + "status": "in_review", + "daily": 50000, + "monthly": 250000, + "per_transaction": 25000, + "supporting_document_file": "https://example.com/doc.pdf", + "supporting_document_type": "individual_bank_statement" + } + ]))) + .mount(&server) + .await; + + let requests = client(&server) + .await + .customers + .get_limit_increase_requests("re_123") + .await + .unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].id, "rl_123"); + assert_eq!(requests[0].receiver_id, "re_123"); + assert_eq!(requests[0].status, blindpay::LimitIncreaseStatus::InReview); + assert_eq!( + requests[0].supporting_document_type, + Some(SupportingDocumentType::IndividualBankStatement) + ); +} + +#[tokio::test] +async fn request_limit_increase_sends_body_and_parses_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/re_123/limit-increase", + )) + .and(body_json(serde_json::json!({ + "per_transaction": 50000, + "daily": 100000, + "monthly": 500000, + "supporting_document_type": "individual_tax_return", + "supporting_document_file": "https://example.com/tax.pdf" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": "rl_456" })), + ) + .mount(&server) + .await; + + let input = RequestLimitIncreaseInput { + per_transaction: 50000, + daily: 100000, + monthly: 500000, + supporting_document_type: SupportingDocumentType::IndividualTaxReturn, + supporting_document_file: "https://example.com/tax.pdf".to_string(), + }; + let resp = client(&server) + .await + .customers + .request_limit_increase("re_123", &input) + .await + .unwrap(); + assert_eq!(resp.id, "rl_456"); +} + +#[tokio::test] +async fn list_customers_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers")) + .respond_with( + ResponseTemplate::new(403).set_body_json(serde_json::json!({ "message": "forbidden" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .customers + .list(&ListCustomersParams::new()) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "forbidden"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --- bank_accounts sub-resource --- + +#[tokio::test] +async fn list_bank_accounts_sends_filters_and_parses_offramp_wallets() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(query_param("type", "pix")) + .and(query_param("status", "approved")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "ba_123", + "type": "pix", + "name": "My PIX", + "pix_key": "11122233344", + "status": "approved", + "offramp_wallets": [ + { + "id": "ow_123", + "external_id": "ext_1", + "network": "tron", + "address": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb" + } + ] + } + ]))) + .mount(&server) + .await; + + let params = ListBankAccountsParams::new() + .rail(Rail::Pix) + .status(BankAccountStatus::Approved); + let accounts = client(&server) + .await + .customers + .bank_accounts + .list("re_123", ¶ms) + .await + .unwrap(); + + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "ba_123"); + assert_eq!(accounts[0].type_, Rail::Pix); + assert_eq!(accounts[0].pix_key.as_deref(), Some("11122233344")); + let wallets = accounts[0].offramp_wallets.as_ref().unwrap(); + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].id, "ow_123"); +} + +#[tokio::test] +async fn list_bank_accounts_decodes_null_offramp_wallets() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "ba_123", + "type": "pix", + "name": "My PIX", + "offramp_wallets": null + } + ]))) + .mount(&server) + .await; + + let accounts = client(&server) + .await + .customers + .bank_accounts + .list("re_123", &ListBankAccountsParams::new()) + .await + .unwrap(); + + assert_eq!(accounts.len(), 1); + assert!(accounts[0].offramp_wallets.is_none()); +} + +#[tokio::test] +async fn create_pix_bank_account_injects_type_discriminator() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "pix", + "name": "My PIX", + "pix_key": "11122233344" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_123", + "type": "pix", + "name": "My PIX", + "pix_key": "11122233344" + }))) + .mount(&server) + .await; + + let input = CreatePixInput { + name: "My PIX".to_string(), + pix_key: "11122233344".to_string(), + force_cpf_cnpj: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_pix("re_123", &input) + .await + .unwrap(); + + assert_eq!(account.id, "ba_123"); + assert_eq!(account.type_, Rail::Pix); +} + +#[tokio::test] +async fn create_ach_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "ach", + "name": "US Checking", + "account_class": "individual", + "account_number": "1001001234", + "account_type": "checking", + "beneficiary_name": "Ana Silva", + "routing_number": "012345678", + "recipient_relationship": "first_party", + "address_line_1": "1 Main St", + "city": "New York", + "state_province_region": "NY", + "country": "US", + "postal_code": "10001" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_456", + "type": "ach", + "name": "US Checking", + "account_class": "individual", + "recipient_relationship": "first_party" + }))) + .mount(&server) + .await; + + let input = CreateAchInput { + name: "US Checking".to_string(), + account_class: AccountClass::Individual, + account_number: "1001001234".to_string(), + account_type: blindpay::AccountType::Checking, + beneficiary_name: "Ana Silva".to_string(), + routing_number: "012345678".to_string(), + recipient_relationship: RecipientRelationship::FirstParty, + address_line_1: "1 Main St".to_string(), + city: "New York".to_string(), + state_province_region: "NY".to_string(), + country: blindpay::Country::from("US"), + postal_code: "10001".to_string(), + address_line_2: None, + business_industry: None, + phone_number: None, + tax_id: None, + date_of_birth: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_ach("re_123", &input) + .await + .unwrap(); + + assert_eq!(account.id, "ba_456"); + assert_eq!(account.type_, Rail::Ach); + assert_eq!( + account.recipient_relationship, + Some(RecipientRelationship::FirstParty) + ); +} + +#[tokio::test] +async fn create_pix_safe_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "pix_safe", + "name": "PIX Safe", + "beneficiary_name": "Ana Silva", + "account_number": "12345", + "account_type": "checking", + "pix_safe_bank_code": "001", + "pix_safe_branch_code": "0001", + "pix_safe_cpf_cnpj": "11122233344" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_ps", + "type": "pix_safe", + "name": "PIX Safe" + }))) + .mount(&server) + .await; + + let input = blindpay::CreatePixSafeInput { + name: "PIX Safe".to_string(), + beneficiary_name: "Ana Silva".to_string(), + account_number: "12345".to_string(), + account_type: blindpay::AccountType::Checking, + pix_safe_bank_code: "001".to_string(), + pix_safe_branch_code: "0001".to_string(), + pix_safe_cpf_cnpj: "11122233344".to_string(), + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_pix_safe("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_ps"); +} + +#[tokio::test] +async fn create_ted_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "ted", + "name": "TED", + "beneficiary_name": "Ana Silva", + "account_number": "12345", + "account_type": "saving", + "ted_bank_code": "341", + "ted_branch_code": "0001", + "ted_cpf_cnpj": "11122233344" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_ted", + "type": "ted", + "name": "TED" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateTedInput { + name: "TED".to_string(), + beneficiary_name: "Ana Silva".to_string(), + account_number: "12345".to_string(), + account_type: blindpay::AccountType::Saving, + ted_bank_code: "341".to_string(), + ted_branch_code: "0001".to_string(), + ted_cpf_cnpj: "11122233344".to_string(), + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_ted("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_ted"); +} + +#[tokio::test] +async fn create_spei_bitso_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "spei_bitso", + "name": "SPEI", + "beneficiary_name": "Juan Perez", + "spei_protocol": "clabe", + "spei_clabe": "012345678901234567" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_spei", + "type": "spei_bitso", + "name": "SPEI" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateSpeiBitsoInput { + name: "SPEI".to_string(), + beneficiary_name: "Juan Perez".to_string(), + spei_protocol: blindpay::SpeiProtocol::Clabe, + spei_clabe: "012345678901234567".to_string(), + spei_institution_code: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_spei_bitso("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_spei"); +} + +#[tokio::test] +async fn create_transfers_bitso_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "transfers_bitso", + "name": "Transfers", + "beneficiary_name": "Juan Perez", + "transfers_type": "CVU", + "transfers_account": "0000000000000000000000" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_tr", + "type": "transfers_bitso", + "name": "Transfers" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateTransfersBitsoInput { + name: "Transfers".to_string(), + beneficiary_name: "Juan Perez".to_string(), + transfers_type: blindpay::TransfersType::Cvu, + transfers_account: "0000000000000000000000".to_string(), + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_transfers_bitso("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_tr"); +} + +#[tokio::test] +async fn create_ach_cop_bitso_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "ach_cop_bitso", + "name": "ACH COP", + "account_type": "checking", + "ach_cop_beneficiary_first_name": "Juan", + "ach_cop_beneficiary_last_name": "Perez", + "ach_cop_document_id": "1234567890", + "ach_cop_document_type": "CC", + "ach_cop_email": "juan@example.com", + "ach_cop_bank_code": "1007", + "ach_cop_bank_account": "12345678" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_cop", + "type": "ach_cop_bitso", + "name": "ACH COP" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateAchCopBitsoInput { + name: "ACH COP".to_string(), + account_type: blindpay::AccountType::Checking, + ach_cop_beneficiary_first_name: "Juan".to_string(), + ach_cop_beneficiary_last_name: "Perez".to_string(), + ach_cop_document_id: "1234567890".to_string(), + ach_cop_document_type: blindpay::AchCopDocument::Cc, + ach_cop_email: "juan@example.com".to_string(), + ach_cop_bank_code: "1007".to_string(), + ach_cop_bank_account: "12345678".to_string(), + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_ach_cop_bitso("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_cop"); +} + +#[tokio::test] +async fn create_wire_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "wire", + "name": "US Wire", + "account_class": "individual", + "account_number": "1001001234", + "beneficiary_name": "Ana Silva", + "routing_number": "012345678", + "recipient_relationship": "first_party", + "address_line_1": "1 Main St", + "city": "New York", + "state_province_region": "NY", + "country": "US", + "postal_code": "10001" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_wire", + "type": "wire", + "name": "US Wire" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateWireInput { + name: "US Wire".to_string(), + account_class: AccountClass::Individual, + account_number: "1001001234".to_string(), + beneficiary_name: "Ana Silva".to_string(), + routing_number: "012345678".to_string(), + recipient_relationship: RecipientRelationship::FirstParty, + address_line_1: "1 Main St".to_string(), + city: "New York".to_string(), + state_province_region: "NY".to_string(), + country: blindpay::Country::from("US"), + postal_code: "10001".to_string(), + address_line_2: None, + business_industry: None, + phone_number: None, + tax_id: None, + date_of_birth: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_wire("re_123", &input) + .await + .unwrap(); + assert_eq!(account.type_, Rail::Wire); +} + +#[tokio::test] +async fn create_rtp_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "rtp", + "name": "RTP", + "account_class": "individual", + "beneficiary_name": "Ana Silva", + "routing_number": "012345678", + "account_number": "1001001234", + "recipient_relationship": "first_party", + "address_line_1": "1 Main St", + "city": "New York", + "state_province_region": "NY", + "country": "US", + "postal_code": "10001" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_rtp", + "type": "rtp", + "name": "RTP" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateRtpInput { + name: "RTP".to_string(), + account_class: AccountClass::Individual, + beneficiary_name: "Ana Silva".to_string(), + routing_number: "012345678".to_string(), + account_number: "1001001234".to_string(), + recipient_relationship: RecipientRelationship::FirstParty, + address_line_1: "1 Main St".to_string(), + city: "New York".to_string(), + state_province_region: "NY".to_string(), + country: blindpay::Country::from("US"), + postal_code: "10001".to_string(), + address_line_2: None, + business_industry: None, + phone_number: None, + tax_id: None, + date_of_birth: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_rtp("re_123", &input) + .await + .unwrap(); + assert_eq!(account.type_, Rail::Rtp); +} + +#[tokio::test] +async fn create_international_swift_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "international_swift", + "name": "SWIFT", + "account_class": "individual", + "recipient_relationship": "first_party", + "swift_account_holder_name": "Ana Silva", + "swift_account_number_iban": "DE89370400440532013000", + "swift_code_bic": "DEUTDEFF", + "swift_beneficiary_address_line_1": "1 Main St", + "swift_beneficiary_city": "Berlin", + "swift_beneficiary_country": "DE", + "swift_beneficiary_postal_code": "10115", + "swift_beneficiary_state_province_region": "BE", + "swift_bank_name": "Deutsche Bank", + "swift_bank_address_line_1": "Bank St", + "swift_bank_city": "Frankfurt", + "swift_bank_country": "DE", + "swift_bank_postal_code": "60311", + "swift_bank_state_province_region": "HE" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_swift", + "type": "international_swift", + "name": "SWIFT" + }))) + .mount(&server) + .await; + + let input = CreateInternationalSwiftInput { + name: "SWIFT".to_string(), + account_class: AccountClass::Individual, + recipient_relationship: RecipientRelationship::FirstParty, + swift_account_holder_name: "Ana Silva".to_string(), + swift_account_number_iban: "DE89370400440532013000".to_string(), + swift_code_bic: "DEUTDEFF".to_string(), + swift_beneficiary_address_line_1: "1 Main St".to_string(), + swift_beneficiary_city: "Berlin".to_string(), + swift_beneficiary_country: blindpay::Country::from("DE"), + swift_beneficiary_postal_code: "10115".to_string(), + swift_beneficiary_state_province_region: "BE".to_string(), + swift_bank_name: "Deutsche Bank".to_string(), + swift_bank_address_line_1: "Bank St".to_string(), + swift_bank_city: "Frankfurt".to_string(), + swift_bank_country: blindpay::Country::from("DE"), + swift_bank_postal_code: "60311".to_string(), + swift_bank_state_province_region: "HE".to_string(), + swift_beneficiary_address_line_2: None, + swift_bank_address_line_2: None, + swift_intermediary_bank_swift_code_bic: None, + swift_intermediary_bank_account_number_iban: None, + swift_intermediary_bank_name: None, + swift_intermediary_bank_country: None, + swift_payment_code: None, + swift_ifsc_branch_code: None, + business_industry: None, + phone_number: None, + tax_id: None, + date_of_birth: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_international_swift("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_swift"); +} + +#[tokio::test] +async fn create_sepa_bank_account_sends_typed_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .and(body_json(serde_json::json!({ + "type": "sepa", + "name": "SEPA", + "account_class": "individual", + "sepa_iban": "DE89370400440532013000", + "sepa_beneficiary_bic": "DEUTDEFF", + "sepa_beneficiary_legal_name": "Ana Silva", + "sepa_beneficiary_address_line_1": "1 Main St", + "sepa_beneficiary_city": "Berlin", + "sepa_beneficiary_postal_code": "10115", + "sepa_beneficiary_country": "DE" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_sepa", + "type": "sepa", + "name": "SEPA" + }))) + .mount(&server) + .await; + + let input = blindpay::CreateSepaInput { + name: "SEPA".to_string(), + account_class: AccountClass::Individual, + sepa_iban: "DE89370400440532013000".to_string(), + sepa_beneficiary_bic: "DEUTDEFF".to_string(), + sepa_beneficiary_legal_name: "Ana Silva".to_string(), + sepa_beneficiary_address_line_1: "1 Main St".to_string(), + sepa_beneficiary_city: "Berlin".to_string(), + sepa_beneficiary_postal_code: "10115".to_string(), + sepa_beneficiary_country: blindpay::Country::from("DE"), + sepa_beneficiary_address_line_2: None, + sepa_beneficiary_state_province_region: None, + }; + let account = client(&server) + .await + .customers + .bank_accounts + .create_sepa("re_123", &input) + .await + .unwrap(); + assert_eq!(account.id, "ba_sepa"); +} + +#[tokio::test] +async fn get_bank_account_parses_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/re_123/bank-accounts/ba_123", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ba_123", + "type": "wire", + "name": "Wire account" + }))) + .mount(&server) + .await; + + let account = client(&server) + .await + .customers + .bank_accounts + .get("re_123", "ba_123") + .await + .unwrap(); + assert_eq!(account.type_, Rail::Wire); +} + +#[tokio::test] +async fn delete_bank_account_returns_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/instances/in_test/customers/re_123/bank-accounts/ba_123", + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let resp = client(&server) + .await + .customers + .bank_accounts + .delete("re_123", "ba_123") + .await + .unwrap(); + assert!(resp.success); +} + +#[tokio::test] +async fn bank_accounts_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers/re_123/bank-accounts")) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({ "message": "not found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .customers + .bank_accounts + .list("re_123", &ListBankAccountsParams::new()) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/fees.rs b/tests/fees.rs new file mode 100644 index 0000000..7c88166 --- /dev/null +++ b/tests/fees.rs @@ -0,0 +1,152 @@ +//! Integration tests for the `fees` resource, backed by a mock HTTP server. + +use blindpay::{BlindPay, Error}; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +fn fee_options(flat: i64) -> serde_json::Value { + serde_json::json!({ + "payin_flat": flat, + "payin_percentage": 50, + "payout_flat": flat, + "payout_percentage": 50 + }) +} + +#[tokio::test] +async fn get_parses_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/billing/fees")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "ach": fee_options(40), + "domestic_wire": fee_options(40), + "rtp": fee_options(40), + "international_swift": fee_options(40), + "pix": fee_options(10), + "pix_safe": fee_options(10), + "ted": fee_options(15), + "ach_colombia": fee_options(20), + "transfers_3": fee_options(20), + "spei": fee_options(25), + "sepa": fee_options(30), + "tron": fee_options(5), + "ethereum": fee_options(5), + "polygon": fee_options(5), + "base": fee_options(5), + "arbitrum": fee_options(5), + "stellar": fee_options(5), + "solana": fee_options(5), + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let fee = client(&server).await.fees.get().await.unwrap(); + + assert_eq!(fee.id, "fe_123"); + assert_eq!(fee.instance_id, "in_test"); + assert_eq!(fee.ach.payin_flat, 40); + assert_eq!(fee.pix.payin_flat, 10); + assert_eq!(fee.sepa.payout_flat, 30); + assert_eq!(fee.solana.payin_percentage, 50); + assert_eq!(fee.ted.as_ref().unwrap().payin_flat, 15); + assert_eq!(fee.created_at, "2024-01-01T00:00:00.000Z"); +} + +#[tokio::test] +async fn get_handles_absent_ted() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/billing/fees")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "ach": fee_options(40), + "domestic_wire": fee_options(40), + "rtp": fee_options(40), + "international_swift": fee_options(40), + "pix": fee_options(10), + "pix_safe": fee_options(10), + "ach_colombia": fee_options(20), + "transfers_3": fee_options(20), + "spei": fee_options(25), + "sepa": fee_options(30), + "tron": fee_options(5), + "ethereum": fee_options(5), + "polygon": fee_options(5), + "base": fee_options(5), + "arbitrum": fee_options(5), + "stellar": fee_options(5), + "solana": fee_options(5), + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let fee = client(&server).await.fees.get().await.unwrap(); + assert!(fee.ted.is_none()); +} + +#[tokio::test] +async fn get_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/billing/fees")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "User not allowed" + }))) + .mount(&server) + .await; + + let err = client(&server).await.fees.get().await.unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "User not allowed"); + assert!(api.raw_body.contains("User not allowed")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_not_found_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/billing/fees")) + .respond_with( + ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "message": "Instance not found" })), + ) + .mount(&server) + .await; + + let err = client(&server).await.fees.get().await.unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "Instance not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/instances.rs b/tests/instances.rs new file mode 100644 index 0000000..c599d83 --- /dev/null +++ b/tests/instances.rs @@ -0,0 +1,673 @@ +//! Integration tests for the `instances` resource and its `tos` / +//! `webhook_endpoints` sub-resources, backed by a mock HTTP server. + +use blindpay::{ + BlindPay, CreateWebhookEndpointInput, Error, InitiateTosInput, UpdateInstanceInput, UserRole, + WebhookEvent, +}; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn get_members_parses_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/members")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "us_000000000000", + "email": "owner@example.com", + "first_name": "Harry", + "middle_name": "James", + "last_name": "Potter", + "image_url": "https://example.com/image.png", + "has_passkey": true, + "role": "owner", + "created_at": "2024-01-01T00:00:00.000Z" + }, + { + "id": "us_000000000001", + "email": "dev@example.com", + "first_name": "Ron", + "last_name": "Weasley", + "image_url": "https://example.com/ron.png", + "role": "developer", + "created_at": "2024-01-02T00:00:00.000Z" + } + ]))) + .mount(&server) + .await; + + let members = client(&server).await.instances.get_members().await.unwrap(); + + assert_eq!(members.len(), 2); + assert_eq!(members[0].id, "us_000000000000"); + assert_eq!(members[0].middle_name.as_deref(), Some("James")); + assert_eq!(members[0].has_passkey, Some(true)); + assert_eq!(members[0].role, UserRole::Owner); + // Second member omits the nullish fields entirely. + assert_eq!(members[1].middle_name, None); + assert_eq!(members[1].has_passkey, None); + assert_eq!(members[1].role, UserRole::Developer); +} + +#[tokio::test] +async fn update_sends_body_and_parses_success() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/instances/in_test")) + .and(body_json(serde_json::json!({ + "name": "Acme", + "require_passkey": true + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let body = UpdateInstanceInput { + require_passkey: Some(true), + ..UpdateInstanceInput::new("Acme") + }; + let res = client(&server).await.instances.update(&body).await.unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn delete_sends_delete_and_parses_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let res = client(&server).await.instances.delete().await.unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn update_member_role_sends_user_role_body() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/instances/in_test/members/us_000000000001")) + .and(body_json(serde_json::json!({ "user_role": "finance" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .update_member_role("us_000000000001", UserRole::Finance) + .await + .unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn delete_member_sends_delete() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test/members/us_000000000001")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .delete_member("us_000000000001") + .await + .unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn migrate_ownership_sends_user_id_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/ownership")) + .and(body_json( + serde_json::json!({ "user_id": "us_000000000001" }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .migrate_ownership("us_000000000001") + .await + .unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn get_members_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/members")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .get_members() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn update_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/instances/in_test")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .update(&UpdateInstanceInput::new("Acme")) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn delete_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server).await.instances.delete().await.unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn update_member_role_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/v1/instances/in_test/members/us_000000000001")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .update_member_role("us_000000000001", UserRole::Finance) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn delete_member_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test/members/us_000000000001")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .delete_member("us_000000000001") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn migrate_ownership_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/ownership")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .migrate_ownership("us_000000000001") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --- tos --- + +#[tokio::test] +async fn tos_initiate_uses_external_path_and_returns_url() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/e/instances/in_test/tos")) + .and(body_json(serde_json::json!({ + "idempotency_key": "123e4567-e89b-12d3-a456-426614174000", + "receiver_id": "cus_123" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "url": "https://tos.blindpay.com/abc" + }))) + .mount(&server) + .await; + + let body = InitiateTosInput { + receiver_id: Some("cus_123".to_string()), + ..InitiateTosInput::new("123e4567-e89b-12d3-a456-426614174000") + }; + let res = client(&server) + .await + .instances + .tos + .initiate(&body) + .await + .unwrap(); + assert_eq!(res.url, "https://tos.blindpay.com/abc"); +} + +#[tokio::test] +async fn tos_initiate_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/e/instances/in_test/tos")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "idempotency_key_already_exists" + }))) + .mount(&server) + .await; + + let body = InitiateTosInput::new("123e4567-e89b-12d3-a456-426614174000"); + let err = client(&server) + .await + .instances + .tos + .initiate(&body) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "idempotency_key_already_exists"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --- webhook_endpoints --- + +#[tokio::test] +async fn webhook_endpoints_list_parses_events() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/webhook-endpoints")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "we_000000000000", + "url": "https://example.com/webhook", + "events": ["payin.complete", "payout.partnerFee"], + "last_event_at": "2024-01-01T00:00:00.000Z", + "instance_id": "in_test", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z" + } + ]))) + .mount(&server) + .await; + + let endpoints = client(&server) + .await + .instances + .webhook_endpoints + .list() + .await + .unwrap(); + + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].id, "we_000000000000"); + assert_eq!( + endpoints[0].events, + vec![WebhookEvent::PayinComplete, WebhookEvent::PayoutPartnerFee] + ); + assert_eq!( + endpoints[0].last_event_at.as_deref(), + Some("2024-01-01T00:00:00.000Z") + ); +} + +#[tokio::test] +async fn webhook_endpoints_create_sends_body_and_returns_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/webhook-endpoints")) + .and(body_json(serde_json::json!({ + "url": "https://example.com/webhook", + "events": ["payin.complete"] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "we_000000000000" + }))) + .mount(&server) + .await; + + let body = CreateWebhookEndpointInput::new( + "https://example.com/webhook", + vec![WebhookEvent::PayinComplete], + ); + let res = client(&server) + .await + .instances + .webhook_endpoints + .create(&body) + .await + .unwrap(); + assert_eq!(res.id, "we_000000000000"); +} + +#[tokio::test] +async fn webhook_endpoints_delete_sends_delete() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/we_000000000000", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .webhook_endpoints + .delete("we_000000000000") + .await + .unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn webhook_endpoints_get_secret_returns_key() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/we_000000000000/secret", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "key": "whsec_000000000000" + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .webhook_endpoints + .get_secret("we_000000000000") + .await + .unwrap(); + assert_eq!(res.key, "whsec_000000000000"); +} + +#[tokio::test] +async fn webhook_endpoints_get_portal_access_url_returns_url() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/portal-access", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "url": "https://portal.svix.com/abc" + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .instances + .webhook_endpoints + .get_portal_access_url() + .await + .unwrap(); + assert_eq!(res.url, "https://portal.svix.com/abc"); +} + +#[tokio::test] +async fn webhook_endpoints_list_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/webhook-endpoints")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "message": "internal error" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .webhook_endpoints + .list() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 500); + assert_eq!(api.message, "internal error"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn webhook_endpoints_create_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/webhook-endpoints")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "invalid_url" + }))) + .mount(&server) + .await; + + let body = CreateWebhookEndpointInput::new( + "https://example.com/webhook", + vec![WebhookEvent::PayinComplete], + ); + let err = client(&server) + .await + .instances + .webhook_endpoints + .create(&body) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid_url"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn webhook_endpoints_delete_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/we_000000000000", + )) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "webhook_endpoint_not_found" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .webhook_endpoints + .delete("we_000000000000") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "webhook_endpoint_not_found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn webhook_endpoints_get_secret_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/we_000000000000/secret", + )) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "webhook_endpoint_not_found" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .webhook_endpoints + .get_secret("we_000000000000") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "webhook_endpoint_not_found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn webhook_endpoints_get_portal_access_url_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/webhook-endpoints/portal-access", + )) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "message": "internal error" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .instances + .webhook_endpoints + .get_portal_access_url() + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 500); + assert_eq!(api.message, "internal error"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/partner_fees.rs b/tests/partner_fees.rs new file mode 100644 index 0000000..fc15ce3 --- /dev/null +++ b/tests/partner_fees.rs @@ -0,0 +1,292 @@ +//! Integration tests for the `partner_fees` resource, backed by a mock HTTP server. + +use blindpay::{BlindPay, CreatePartnerFeeInput, Error}; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn list_parses_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/partner-fees")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 100, + "payout_flat_fee": 1000, + "payin_percentage_fee": 50, + "payin_flat_fee": 500, + "virtual_account_set": false, + "created_at": "2021-01-01T00:00:00Z", + "updated_at": "2021-01-02T00:00:00Z" + } + ]))) + .mount(&server) + .await; + + let fees = client(&server).await.partner_fees.list().await.unwrap(); + + assert_eq!(fees.len(), 1); + assert_eq!(fees[0].id, "fe_123"); + assert_eq!(fees[0].name, "Display Name"); + assert_eq!(fees[0].payout_flat_fee, 1000); + assert_eq!(fees[0].virtual_account_set, Some(false)); + assert_eq!(fees[0].created_at.as_deref(), Some("2021-01-01T00:00:00Z")); +} + +#[tokio::test] +async fn get_parses_create_out_shape() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/partner-fees/fe_123")) + .and(header("authorization", "Bearer test-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 0, + "payout_flat_fee": 0, + "payin_percentage_fee": 0, + "payin_flat_fee": 0 + }))) + .mount(&server) + .await; + + let fee = client(&server) + .await + .partner_fees + .get("fe_123") + .await + .unwrap(); + + assert_eq!(fee.id, "fe_123"); + assert_eq!(fee.virtual_account_set, None); + assert_eq!(fee.created_at, None); +} + +#[tokio::test] +async fn create_sends_body_and_parses_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/partner-fees")) + .and(header("authorization", "Bearer test-api-key")) + .and(body_json(serde_json::json!({ + "name": "Display Name", + "payout_percentage_fee": 100, + "payout_flat_fee": 1000, + "payin_percentage_fee": 50, + "payin_flat_fee": 500 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 100, + "payout_flat_fee": 1000, + "payin_percentage_fee": 50, + "payin_flat_fee": 500 + }))) + .mount(&server) + .await; + + let input = CreatePartnerFeeInput::new("Display Name", 100, 1000, 50, 500); + let fee = client(&server) + .await + .partner_fees + .create(&input) + .await + .unwrap(); + + assert_eq!(fee.id, "fe_123"); + assert_eq!(fee.payin_flat_fee, 500); +} + +#[tokio::test] +async fn create_serializes_virtual_account_set_when_set() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/partner-fees")) + .and(body_json(serde_json::json!({ + "name": "Display Name", + "payout_percentage_fee": 0, + "payout_flat_fee": 0, + "payin_percentage_fee": 0, + "payin_flat_fee": 0, + "virtual_account_set": true + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "fe_123", + "instance_id": "in_test", + "name": "Display Name", + "payout_percentage_fee": 0, + "payout_flat_fee": 0, + "payin_percentage_fee": 0, + "payin_flat_fee": 0 + }))) + .mount(&server) + .await; + + let mut input = CreatePartnerFeeInput::new("Display Name", 0, 0, 0, 0); + input.virtual_account_set = Some(true); + let fee = client(&server) + .await + .partner_fees + .create(&input) + .await + .unwrap(); + + assert_eq!(fee.id, "fe_123"); +} + +#[tokio::test] +async fn delete_returns_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test/partner-fees/fe_123")) + .and(header("authorization", "Bearer test-api-key")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let result = client(&server) + .await + .partner_fees + .delete("fe_123") + .await + .unwrap(); + + assert!(result.success); +} + +#[tokio::test] +async fn empty_id_fails_before_any_request() { + let client = BlindPay::new("test-api-key", "in_test").unwrap(); + let err = client.partner_fees.get(" ").await.unwrap_err(); + assert!(matches!(err, Error::Config(_))); + + let err = client.partner_fees.delete(" ").await.unwrap_err(); + assert!(matches!(err, Error::Config(_))); +} + +#[tokio::test] +async fn list_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/partner-fees")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "User not allowed" + }))) + .mount(&server) + .await; + + let err = client(&server).await.partner_fees.list().await.unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "User not allowed"); + assert!(api.raw_body.contains("User not allowed")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn create_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/partner-fees")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "virtual_account_partner_fee_already_exists" + }))) + .mount(&server) + .await; + + let mut input = CreatePartnerFeeInput::new("Display Name", 0, 0, 0, 0); + input.virtual_account_set = Some(true); + let err = client(&server) + .await + .partner_fees + .create(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "virtual_account_partner_fee_already_exists"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn delete_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/instances/in_test/partner-fees/fe_123")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "message": "user_not_allowed" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .partner_fees + .delete("fe_123") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/partner-fees/fe_123")) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({ "message": "not found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .partner_fees + .get("fe_123") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/payins.rs b/tests/payins.rs new file mode 100644 index 0000000..17fa0ef --- /dev/null +++ b/tests/payins.rs @@ -0,0 +1,567 @@ +//! Integration tests for the `payins` resource and its `quotes` sub-resource, +//! backed by a mock HTTP server. + +use blindpay::{ + AccountClass, BlindPay, CreatePayinInput, CreatePayinQuoteInput, Currency, CurrencyType, Error, + Limit, ListPayinsParams, PayerRules, PayinQuoteFxInput, PaymentMethod, PseDocumentType, Token, + TrackingStatus, TransactionStatus, TransfersType, +}; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn create_quote_sends_body_and_parses_response() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payin-quotes")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .and(body_json(serde_json::json!({ + "blockchain_wallet_id": "bw_123", + "currency_type": "sender", + "cover_fees": true, + "request_amount": 1000, + "payment_method": "pix", + "token": "USDC", + "payer_rules": { "pix_allowed_tax_ids": ["123.456.789-09"] } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pq_000000000000", + "expires_at": 1712958191, + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "receiver_amount": 1010, + "sender_amount": 5240, + "partner_fee_amount": 150, + "flat_fee": 50, + "billing_fee_amount": 50, + "is_otc": false + }))) + .mount(&server) + .await; + + let input = CreatePayinQuoteInput { + blockchain_wallet_id: Some("bw_123".to_string()), + currency_type: CurrencyType::Sender, + cover_fees: true, + request_amount: 1000, + payment_method: PaymentMethod::Pix, + token: Token::Usdc, + payer_rules: Some(PayerRules { + pix_allowed_tax_ids: Some(vec!["123.456.789-09".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let quote = client(&server) + .await + .payins + .quotes + .create(&input) + .await + .unwrap(); + + assert_eq!(quote.id, "pq_000000000000"); + assert_eq!(quote.sender_amount, 5240); + assert_eq!(quote.receiver_amount, 1010); + assert_eq!(quote.partner_fee_amount, Some(150)); + assert_eq!(quote.is_otc, Some(false)); +} + +#[tokio::test] +async fn get_fx_rate_sends_body_and_parses_response() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payin-quotes/fx")) + .and(body_json(serde_json::json!({ + "from": "BRL", + "to": "USDC", + "request_amount": 1000, + "currency_type": "sender" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "result_amount": 1010, + "instance_flat_fee": 50, + "instance_percentage_fee": 100 + }))) + .mount(&server) + .await; + + let fx = client(&server) + .await + .payins + .quotes + .get_fx_rate(&PayinQuoteFxInput { + from: Currency::Brl, + to: Token::Usdc, + request_amount: 1000, + currency_type: CurrencyType::Sender, + }) + .await + .unwrap(); + + assert_eq!(fx.result_amount, 1010); + assert_eq!(fx.instance_flat_fee, 50); + assert_eq!(fx.instance_percentage_fee, 100); +} + +#[tokio::test] +async fn create_evm_sends_payin_quote_id_and_parses_response() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payins/evm")) + .and(body_json(serde_json::json!({ + "payin_quote_id": "pq_000000000000" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pi_000000000000", + "status": "processing", + "pix_code": "00020101...6304BCAB", + "memo_code": null, + "clabe": null, + "partner_fee": 25000, + "tracking_complete": { "step": "processing" }, + "tracking_payment": { "step": "processing" }, + "tracking_transaction": { "step": "processing", "external_id": null }, + "billing_fee_amount": 50, + "transaction_fee_amount": 100, + "blindpay_bank_details": { + "routing_number": "121145349", + "account_number": "621327727210181", + "account_type": "Business checking", + "beneficiary": { + "name": "BlindPay, Inc.", + "address_line_1": "8 The Green, #19364", + "address_line_2": "Dover, DE 19901" + }, + "receiving_bank": { + "name": "CFSB", + "address_line_1": "1 Letterman Drive", + "address_line_2": "San Francisco, CA 94129" + } + }, + "receiver_id": "re_000000000000", + "receiver_amount": 1010, + "payment_method": "pix", + "sender_amount": 5240 + }))) + .mount(&server) + .await; + + let payin = client(&server) + .await + .payins + .create_evm(&CreatePayinInput { + payin_quote_id: "pq_000000000000".to_string(), + }) + .await + .unwrap(); + + assert_eq!(payin.id, "pi_000000000000"); + assert_eq!(payin.status, TransactionStatus::Processing); + assert_eq!(payin.partner_fee, Some(25000)); + assert_eq!(payin.payment_method, Some(PaymentMethod::Pix)); + assert_eq!(payin.blindpay_bank_details.routing_number, "121145349"); + assert_eq!(payin.receiver_amount, Some(1010)); +} + +#[tokio::test] +async fn list_sends_pagination_and_filters_and_parses_paginated() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins")) + .and(query_param("limit", "50")) + .and(query_param("status", "completed")) + .and(query_param("customer_id", "re_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [ + { + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "status": "completed", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { + "step": "completed", + "status": "completed", + "external_id": "12345678", + "provider_name": "HSBC" + }, + "tracking_payment": { "step": "completed" }, + "tracking_complete": { "step": "completed", "transaction_hash": "0xabc" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "type": "individual", + "payment_method": "pix", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "BRL", + "network": "base" + } + ], + "pagination": { "has_more": false, "next_page": null, "prev_page": null } + }))) + .mount(&server) + .await; + + let params = ListPayinsParams::new() + .limit(Limit::Fifty) + .status(TransactionStatus::Completed) + .customer_id("re_000000000000"); + + let resp = client(&server).await.payins.list(¶ms).await.unwrap(); + + assert_eq!(resp.data().len(), 1); + let payin = &resp.data()[0]; + assert_eq!(payin.id, "pi_000000000000"); + assert_eq!(payin.account_class, AccountClass::Individual); + assert_eq!(payin.status, TransactionStatus::Completed); + assert_eq!(payin.tracking_transaction.step, TrackingStatus::Completed); + assert_eq!( + payin.tracking_transaction.external_id.as_deref(), + Some("12345678") + ); + assert_eq!( + payin + .tracking_transaction + .provider_name + .as_ref() + .map(|p| p.as_str()), + Some("HSBC") + ); + assert!(resp.pagination().is_some()); + assert!(!resp.pagination().unwrap().has_more); +} + +#[tokio::test] +async fn list_handles_bare_array_response() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "status": "processing", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { "step": "processing", "external_id": null }, + "tracking_payment": { "step": "processing" }, + "tracking_complete": { "step": "processing" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "business", + "payment_method": "wire", + "sender_amount": 10000, + "receiver_amount": 9800, + "token": "USDC", + "commercial_quotation": 1.0, + "blindpay_quotation": 1.02, + "currency": "USD" + } + ]))) + .mount(&server) + .await; + + let resp = client(&server) + .await + .payins + .list(&ListPayinsParams::new()) + .await + .unwrap(); + + assert_eq!(resp.data().len(), 1); + assert_eq!(resp.data()[0].account_class, AccountClass::Business); + assert!(resp.pagination().is_none()); +} + +#[tokio::test] +async fn get_parses_full_payin() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins/pi_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "pix_code": "00020101...6304BCAB", + "status": "on_hold", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { + "step": "processing", + "external_id": null, + "pse_instruction": { + "payment_link": "https://pse.example.com/payment/abc123", + "fid": "fid_abc123", + "full_name": "Juan Perez", + "tax_id": "1234567890", + "document_type": "CC", + "phone": "+573001234567", + "email": "juan@example.com" + } + }, + "tracking_payment": { "step": "on_hold" }, + "tracking_complete": { "step": "processing" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "individual", + "payment_method": "pse", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "COP", + "pse_payment_link": "https://pse.example.com/payment/abc123", + "pse_document_type": "CC" + }))) + .mount(&server) + .await; + + let payin = client(&server) + .await + .payins + .get("pi_000000000000") + .await + .unwrap(); + + assert_eq!(payin.id, "pi_000000000000"); + assert_eq!(payin.status, TransactionStatus::OnHold); + assert_eq!(payin.currency, Currency::Cop); + let pse = payin.tracking_transaction.pse_instruction.as_ref().unwrap(); + assert_eq!(pse.document_type, PseDocumentType::Cc); + assert_eq!(pse.fid, "fid_abc123"); + assert_eq!(payin.pse_document_type, Some(PseDocumentType::Cc)); + assert_eq!( + payin.pse_payment_link.as_deref(), + Some("https://pse.example.com/payment/abc123") + ); +} + +#[tokio::test] +async fn get_parses_transfers_instruction_and_null_tracking_fields() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins/pi_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "status": "processing", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { + "step": "processing", + "status": null, + "external_id": null, + "provider_name": null, + "transfers_instruction": { + "account": "0000003100012389237485", + "type": "CVU", + "tax_id": "20123456783" + } + }, + "tracking_payment": { "step": "processing" }, + "tracking_complete": { "step": "processing" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "individual", + "payment_method": "transfers", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "ARS" + }))) + .mount(&server) + .await; + + let payin = client(&server) + .await + .payins + .get("pi_000000000000") + .await + .unwrap(); + + assert_eq!(payin.tracking_transaction.status, None); + assert_eq!(payin.tracking_transaction.provider_name, None); + let transfers = payin + .tracking_transaction + .transfers_instruction + .as_ref() + .unwrap(); + assert_eq!(transfers.transfers_type, TransfersType::Cvu); + assert_eq!(transfers.account, "0000003100012389237485"); +} + +#[tokio::test] +async fn get_track_uses_unauthenticated_e_route() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/e/payins/pi_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "status": "completed", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { "step": "completed", "external_id": null }, + "tracking_payment": { "step": "completed" }, + "tracking_complete": { "step": "completed" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "individual", + "payment_method": "pix", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "BRL" + }))) + .mount(&server) + .await; + + let payin = client(&server) + .await + .payins + .get_track("pi_000000000000") + .await + .unwrap(); + + assert_eq!(payin.id, "pi_000000000000"); + assert_eq!(payin.status, TransactionStatus::Completed); +} + +#[tokio::test] +async fn unknown_payment_method_does_not_break_decoding() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins/pi_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "pi_000000000000", + "receiver_id": "re_000000000000", + "status": "processing", + "payin_quote_id": "pq_000000000000", + "instance_id": "in_test", + "tracking_transaction": { "step": "processing", "external_id": null }, + "tracking_payment": { "step": "processing" }, + "tracking_complete": { "step": "processing" }, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z", + "type": "individual", + "payment_method": "future_method", + "sender_amount": 5240, + "receiver_amount": 1010, + "token": "USDC", + "commercial_quotation": 4.95, + "blindpay_quotation": 5.05, + "currency": "BRL" + }))) + .mount(&server) + .await; + + let payin = client(&server) + .await + .payins + .get("pi_000000000000") + .await + .unwrap(); + + assert_eq!( + payin.payment_method, + PaymentMethod::Unknown("future_method".to_string()) + ); +} + +#[tokio::test] +async fn create_quote_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payin-quotes")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "invalid request amount" })), + ) + .mount(&server) + .await; + + let input = CreatePayinQuoteInput { + currency_type: CurrencyType::Sender, + cover_fees: false, + request_amount: 100, + payment_method: PaymentMethod::Pix, + token: Token::Usdc, + ..Default::default() + }; + + let err = client(&server) + .await + .payins + .quotes + .create(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid request amount"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payins/pi_missing")) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({ "message": "not found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .payins + .get("pi_missing") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/payouts.rs b/tests/payouts.rs new file mode 100644 index 0000000..50f6eea --- /dev/null +++ b/tests/payouts.rs @@ -0,0 +1,603 @@ +//! Integration tests for the `payouts` resource, backed by a mock HTTP server. + +use blindpay::{ + AuthorizeStellarTokenInput, BlindPay, CreateEvmPayoutInput, CreateSolanaPayoutInput, + CreateStellarPayoutInput, Error, Limit, ListPayoutsParams, PayoutCompleteStatus, + PayoutPaymentProviderStatus, PayoutTrackingStep, ProviderName, Rail, + SubmitPayoutDocumentsInput, Token, TransactionDocumentType, TransactionStatus, +}; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +fn create_out_body() -> serde_json::Value { + serde_json::json!({ + "id": "pa_000000000000", + "status": "processing", + "sender_wallet_address": "0x123456789", + "billing_fee_amount": 100, + "transaction_fee_amount": 250, + "partner_fee": -25000, + "tracking_transaction": { "step": "processing", "transaction_hash": "0xabc" }, + "tracking_payment": { "step": "on_hold", "provider_name": "HSBC" }, + "tracking_complete": { "step": "pending_review" }, + "receiver_id": "re_000000000000", + "bank_account_id": "ba_000000000000", + "offramp_wallet_id": null + }) +} + +#[tokio::test] +async fn create_evm_posts_quote_and_wallet_and_parses_response() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/evm")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .and(body_json(serde_json::json!({ + "quote_id": "qu_000000000000", + "sender_wallet_address": "0x123456789" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(create_out_body())) + .mount(&server) + .await; + + let input = CreateEvmPayoutInput::new("qu_000000000000", "0x123456789"); + let payout = client(&server) + .await + .payouts + .create_evm(&input) + .await + .unwrap(); + + assert_eq!(payout.id, "pa_000000000000"); + assert_eq!(payout.status, TransactionStatus::Processing); + assert_eq!(payout.sender_wallet_address, "0x123456789"); + assert_eq!(payout.billing_fee_amount, Some(100)); + assert_eq!(payout.partner_fee, Some(-25000)); + assert_eq!(payout.receiver_id.as_deref(), Some("re_000000000000")); + assert_eq!(payout.bank_account_id.as_deref(), Some("ba_000000000000")); + assert_eq!( + payout.tracking_complete.unwrap().step, + PayoutTrackingStep::PendingReview + ); +} + +#[tokio::test] +async fn create_solana_includes_optional_signed_transaction() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/solana")) + .and(body_json(serde_json::json!({ + "quote_id": "qu_000000000000", + "sender_wallet_address": "9xQeWv...", + "signed_transaction": "AAA...Zey8y0A" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(create_out_body())) + .mount(&server) + .await; + + let input = CreateSolanaPayoutInput::new("qu_000000000000", "9xQeWv...") + .signed_transaction("AAA...Zey8y0A"); + let payout = client(&server) + .await + .payouts + .create_solana(&input) + .await + .unwrap(); + + assert_eq!(payout.id, "pa_000000000000"); +} + +#[tokio::test] +async fn create_stellar_omits_unset_signed_transaction() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/stellar")) + .and(body_json(serde_json::json!({ + "quote_id": "qu_000000000000", + "sender_wallet_address": "GABC..." + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(create_out_body())) + .mount(&server) + .await; + + let input = CreateStellarPayoutInput::new("qu_000000000000", "GABC..."); + let payout = client(&server) + .await + .payouts + .create_stellar(&input) + .await + .unwrap(); + + assert_eq!(payout.status, TransactionStatus::Processing); +} + +#[tokio::test] +async fn authorize_stellar_token_returns_transaction_hash() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/stellar/authorize")) + .and(body_json(serde_json::json!({ + "quote_id": "qu_000000000000", + "sender_wallet_address": "GABC..." + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "transaction_hash": "0xdeadbeef" + }))) + .mount(&server) + .await; + + let input = AuthorizeStellarTokenInput::new("qu_000000000000", "GABC..."); + let res = client(&server) + .await + .payouts + .authorize_stellar_token(&input) + .await + .unwrap(); + + assert_eq!(res.transaction_hash, "0xdeadbeef"); +} + +#[tokio::test] +async fn submit_documents_posts_to_payout_id_and_omits_unset_description() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/payouts/pa_000000000000/documents", + )) + .and(body_json(serde_json::json!({ + "transaction_document_type": "invoice", + "transaction_document_id": "INV-12345", + "transaction_document_file": "https://example.com/document.pdf" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let input = SubmitPayoutDocumentsInput { + transaction_document_type: TransactionDocumentType::Invoice, + transaction_document_id: "INV-12345".to_string(), + transaction_document_file: "https://example.com/document.pdf".to_string(), + description: None, + }; + let res = client(&server) + .await + .payouts + .submit_documents("pa_000000000000", &input) + .await + .unwrap(); + + assert!(res.success); +} + +#[tokio::test] +async fn list_sends_pagination_and_rail_filter_and_parses_joined_payout() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payouts")) + .and(query_param("limit", "50")) + .and(query_param("payment_method", "pix")) + .and(query_param("status", "completed")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [ + { + "receiver_id": "re_000000000000", + "id": "pa_000000000000", + "status": "completed", + "sender_wallet_address": "0x123456789", + "quote_id": "qu_000000000000", + "instance_id": "in_test", + "network": "polygon", + "token": "USDC", + "currency": "BRL", + "sender_amount": 101000, + "receiver_amount": 500000, + "type": "pix", + "pix_key": "user@example.com", + "transaction_document_type": "invoice", + "created_at": "2021-01-01T00:00:00Z", + "updated_at": "2021-01-02T00:00:00Z" + } + ], + "pagination": { "has_more": false, "next_page": null, "prev_page": null } + }))) + .mount(&server) + .await; + + let params = ListPayoutsParams::new() + .limit(Limit::Fifty) + .payment_method(Rail::Pix) + .status(TransactionStatus::Completed); + let res = client(&server).await.payouts.list(¶ms).await.unwrap(); + + assert_eq!(res.data().len(), 1); + let payout = &res.data()[0]; + assert_eq!(payout.id, "pa_000000000000"); + assert_eq!(payout.status, TransactionStatus::Completed); + assert_eq!(payout.token, Some(Token::Usdc)); + assert_eq!(payout.account_rail, Some(Rail::Pix)); + assert_eq!(payout.pix_key.as_deref(), Some("user@example.com")); + assert_eq!( + payout.transaction_document_type, + Some(TransactionDocumentType::Invoice) + ); + assert!(!res.pagination().unwrap().has_more); +} + +#[tokio::test] +async fn get_parses_joined_payout() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payouts/pa_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "receiver_id": "re_000000000000", + "id": "pa_000000000000", + "status": "processing", + "sender_wallet_address": "0x123456789", + "quote_id": "qu_000000000000", + "instance_id": "in_test", + "first_name": "John", + "last_name": "Doe", + "has_virtual_account": true, + "sender_legal_name": "LRB TRADING INVESTMENT L.L.C", + "tracking_payment": { + "step": "completed", + "provider_name": "JPMorgan Chase", + "provider_status": "sent", + "estimated_time_of_arrival": "1_business_day", + "recipient_name": "Jane Roe" + }, + "tracking_complete": { + "step": "completed", + "status": "paid" + }, + "jpm_track_data": { + "jpm_processing_status": "COMPLETED", + "extended_tracking_status": null + } + }))) + .mount(&server) + .await; + + let payout = client(&server) + .await + .payouts + .get("pa_000000000000") + .await + .unwrap(); + + assert_eq!(payout.id, "pa_000000000000"); + assert_eq!(payout.first_name.as_deref(), Some("John")); + assert_eq!(payout.has_virtual_account, Some(true)); + let payment = payout.tracking_payment.unwrap(); + assert_eq!(payment.step, PayoutTrackingStep::Completed); + assert_eq!(payment.recipient_name.as_deref(), Some("Jane Roe")); + assert_eq!( + payment.provider_name, + Some(ProviderName::from("JPMorgan Chase")) + ); + assert_eq!( + payment.provider_status, + Some(PayoutPaymentProviderStatus::Sent) + ); + assert_eq!( + payout.tracking_complete.unwrap().status, + Some(PayoutCompleteStatus::Paid) + ); + assert!(payout.jpm_track_data.is_some()); +} + +#[tokio::test] +async fn get_track_uses_unauthenticated_e_route() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/e/payouts/pa_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "receiver_id": "re_000000000000", + "id": "pa_000000000000", + "status": "completed", + "sender_wallet_address": "0x123456789", + "quote_id": "qu_000000000000", + "instance_id": "in_test" + }))) + .mount(&server) + .await; + + let payout = client(&server) + .await + .payouts + .get_track("pa_000000000000") + .await + .unwrap(); + + assert_eq!(payout.id, "pa_000000000000"); + assert_eq!(payout.status, TransactionStatus::Completed); +} + +#[tokio::test] +async fn unknown_tracking_step_does_not_break_decoding() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payouts/pa_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "receiver_id": "re_000000000000", + "id": "pa_000000000000", + "status": "on_hold", + "sender_wallet_address": "0x123456789", + "quote_id": "qu_000000000000", + "instance_id": "in_test", + "tracking_transaction": { "step": "future_step" } + }))) + .mount(&server) + .await; + + let payout = client(&server) + .await + .payouts + .get("pa_000000000000") + .await + .unwrap(); + + assert_eq!( + payout.tracking_transaction.unwrap().step, + PayoutTrackingStep::Unknown("future_step".to_string()) + ); +} + +#[tokio::test] +async fn create_evm_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/evm")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "quote expired" + }))) + .mount(&server) + .await; + + let input = CreateEvmPayoutInput::new("qu_000000000000", "0x123456789"); + let err = client(&server) + .await + .payouts + .create_evm(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "quote expired"); + assert!(api.raw_body.contains("quote expired")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payouts/pa_missing")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "payout not found" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .payouts + .get("pa_missing") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "payout not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn create_solana_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/solana")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "invalid signed transaction" + }))) + .mount(&server) + .await; + + let input = CreateSolanaPayoutInput::new("qu_000000000000", "9xQeWv..."); + let err = client(&server) + .await + .payouts + .create_solana(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid signed transaction"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn create_stellar_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/stellar")) + .respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({ + "message": "trustline not authorized" + }))) + .mount(&server) + .await; + + let input = CreateStellarPayoutInput::new("qu_000000000000", "GABC..."); + let err = client(&server) + .await + .payouts + .create_stellar(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 422); + assert_eq!(api.message, "trustline not authorized"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn authorize_stellar_token_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/payouts/stellar/authorize")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "quote not found" + }))) + .mount(&server) + .await; + + let input = AuthorizeStellarTokenInput::new("qu_000000000000", "GABC..."); + let err = client(&server) + .await + .payouts + .authorize_stellar_token(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "quote not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn submit_documents_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/payouts/pa_000000000000/documents", + )) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "message": "unsupported document type" + }))) + .mount(&server) + .await; + + let input = SubmitPayoutDocumentsInput { + transaction_document_type: TransactionDocumentType::Invoice, + transaction_document_id: "INV-12345".to_string(), + transaction_document_file: "https://example.com/document.pdf".to_string(), + description: None, + }; + let err = client(&server) + .await + .payouts + .submit_documents("pa_000000000000", &input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "unsupported document type"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn list_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/payouts")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "message": "unauthorized" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .payouts + .list(&ListPayoutsParams::new()) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 401); + assert_eq!(api.message, "unauthorized"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_track_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/e/payouts/pa_missing")) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "message": "payout not found" + }))) + .mount(&server) + .await; + + let err = client(&server) + .await + .payouts + .get_track("pa_missing") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "payout not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/quotes.rs b/tests/quotes.rs new file mode 100644 index 0000000..cbdd8bd --- /dev/null +++ b/tests/quotes.rs @@ -0,0 +1,284 @@ +//! Integration tests for the `quotes` resource, backed by a mock HTTP server. + +use blindpay::{ + BlindPay, CreateQuoteInput, Currency, CurrencyType, Error, GetFxRateInput, Network, Token, +}; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn create_sends_body_and_parses_quote() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .and(body_json(serde_json::json!({ + "bank_account_id": "ba_123", + "currency_type": "sender", + "cover_fees": true, + "request_amount": 1000, + "network": "sepolia", + "token": "USDC", + "description": "memo", + "partner_fee_id": "pf_123" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "qu_123", + "expires_at": 1712958191_i64, + "commercial_quotation": 4.95, + "blindpay_quotation": 4.85, + "receiver_amount": 5240, + "sender_amount": 1010, + "partner_fee_amount": 150, + "flat_fee": 50, + "billing_fee_amount": 50, + "receiver_local_amount": 1000, + "description": "memo", + "contract": { + "abi": [{}], + "address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "functionName": "approve", + "blindpayContractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "amount": "1000000000000000000", + "network": { "name": "Ethereum", "chainId": 1 } + } + }))) + .mount(&server) + .await; + + let quote = client(&server) + .await + .quotes + .create(&CreateQuoteInput { + bank_account_id: "ba_123".to_string(), + currency_type: CurrencyType::Sender, + cover_fees: true, + request_amount: 1000, + network: Network::Sepolia, + token: Token::Usdc, + description: Some("memo".to_string()), + partner_fee_id: Some("pf_123".to_string()), + }) + .await + .unwrap(); + + assert_eq!(quote.id, "qu_123"); + assert_eq!(quote.sender_amount, 1010); + assert_eq!(quote.partner_fee_amount, Some(150)); + assert_eq!(quote.flat_fee, Some(50)); + assert_eq!(quote.receiver_local_amount, Some(1000)); + + let contract = quote.contract.unwrap(); + assert_eq!(contract.function_name, "approve"); + assert_eq!( + contract.blindpay_contract_address, + "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + ); + assert_eq!(contract.amount, "1000000000000000000"); + assert_eq!(contract.network.name, "Ethereum"); + assert_eq!(contract.network.chain_id, 1); +} + +#[tokio::test] +async fn create_omits_optional_fields_when_absent() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes")) + .and(body_json(serde_json::json!({ + "bank_account_id": "ba_123", + "currency_type": "receiver", + "cover_fees": false, + "request_amount": 500, + "network": "stellar", + "token": "USDC" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "qu_456", + "expires_at": 1712958191_i64, + "commercial_quotation": 4.95, + "blindpay_quotation": 4.85, + "receiver_amount": 2480, + "sender_amount": 500 + }))) + .mount(&server) + .await; + + let quote = client(&server) + .await + .quotes + .create(&CreateQuoteInput { + bank_account_id: "ba_123".to_string(), + currency_type: CurrencyType::Receiver, + cover_fees: false, + request_amount: 500, + network: Network::Stellar, + token: Token::Usdc, + description: None, + partner_fee_id: None, + }) + .await + .unwrap(); + + assert_eq!(quote.id, "qu_456"); + assert_eq!(quote.partner_fee_amount, None); + assert!(quote.contract.is_none()); + assert!(quote.description.is_none()); +} + +#[tokio::test] +async fn get_fx_rate_sends_body_and_parses_response() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes/fx")) + .and(header("authorization", "Bearer test-api-key")) + .and(body_json(serde_json::json!({ + "from": "USDC", + "to": "BRL", + "request_amount": 1000, + "currency_type": "sender" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "commercial_quotation": 4.95, + "blindpay_quotation": 4.85, + "result_amount": 4850, + "instance_flat_fee": 50, + "instance_percentage_fee": 0 + }))) + .mount(&server) + .await; + + let fx = client(&server) + .await + .quotes + .get_fx_rate(&GetFxRateInput { + from: Token::Usdc, + to: Currency::Brl, + request_amount: 1000, + currency_type: CurrencyType::Sender, + }) + .await + .unwrap(); + + assert!((fx.commercial_quotation - 4.95).abs() < 1e-9); + assert_eq!(fx.result_amount, 4850); + assert_eq!(fx.instance_flat_fee, Some(50)); + assert_eq!(fx.instance_percentage_fee, 0); +} + +#[tokio::test] +async fn get_fx_rate_allows_null_instance_flat_fee() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes/fx")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "commercial_quotation": 4.95, + "blindpay_quotation": 4.85, + "result_amount": 4850, + "instance_flat_fee": null, + "instance_percentage_fee": 25 + }))) + .mount(&server) + .await; + + let fx = client(&server) + .await + .quotes + .get_fx_rate(&GetFxRateInput { + from: Token::Usdc, + to: Currency::Brl, + request_amount: 1000, + currency_type: CurrencyType::Sender, + }) + .await + .unwrap(); + + assert_eq!(fx.instance_flat_fee, None); + assert_eq!(fx.instance_percentage_fee, 25); +} + +#[tokio::test] +async fn create_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "bank_account_not_approved" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .quotes + .create(&CreateQuoteInput { + bank_account_id: "ba_123".to_string(), + currency_type: CurrencyType::Sender, + cover_fees: true, + request_amount: 1000, + network: Network::Sepolia, + token: Token::Usdc, + description: None, + partner_fee_id: None, + }) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "bank_account_not_approved"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn get_fx_rate_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/quotes/fx")) + .respond_with( + ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "message": "fee_not_found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .quotes + .get_fx_rate(&GetFxRateInput { + from: Token::Usdc, + to: Currency::Brl, + request_amount: 1000, + currency_type: CurrencyType::Sender, + }) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "fee_not_found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/transfers.rs b/tests/transfers.rs new file mode 100644 index 0000000..b0e2b9a --- /dev/null +++ b/tests/transfers.rs @@ -0,0 +1,322 @@ +//! Integration tests for the `transfers` resource, backed by a mock HTTP server. + +use blindpay::{ + BlindPay, CreateTransferInput, CreateTransferQuoteInput, CurrencyType, Error, Network, + PaginationParams, Token, TrackingStatus, TransactionStatus, +}; +use wiremock::matchers::{body_json, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +fn tracking_step() -> serde_json::Value { + serde_json::json!({ + "step": "processing", + "transaction_hash": null, + "gas_fee": null, + "completed_at": null, + "error_message": null + }) +} + +fn tracking_monitoring() -> serde_json::Value { + serde_json::json!({ + "step": "processing", + "blockchain_screening": null, + "risk_score": null, + "completed_at": null + }) +} + +fn transfer_body() -> serde_json::Value { + serde_json::json!({ + "id": "tr_000000000000", + "status": "processing", + "transfer_quote_id": "qu_000000000000", + "instance_id": "in_test", + "tracking_transaction_monitoring": tracking_monitoring(), + "tracking_paymaster": tracking_step(), + "tracking_bridge_swap": tracking_step(), + "tracking_complete": tracking_step(), + "tracking_partner_fee": tracking_step(), + "created_at": "2025-01-01T00:00:00.000Z", + "updated_at": "2025-01-01T00:00:00.000Z", + "image_url": null, + "first_name": "John", + "last_name": "Doe", + "legal_name": null, + "wallet_id": "bl_000000000000", + "sender_token": "USDC", + "sender_amount": 1000, + "receiver_amount": 1000, + "receiver_network": "polygon", + "receiver_token": "USDC", + "receiver_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "partner_fee_amount": null, + "external_id": "ext_1", + "receiver_id": "re_000000000000", + "address": "0xAbC0000000000000000000000000000000000000", + "network": "polygon" + }) +} + +#[tokio::test] +async fn quotes_create_sends_body_and_parses_response() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/transfer-quotes")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .and(body_json(serde_json::json!({ + "wallet_id": "bl_000000000000", + "amount_reference": "sender", + "request_amount": 1000, + "sender_token": "USDC", + "receiver_wallet_address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "receiver_token": "USDC", + "receiver_network": "polygon", + "cover_fees": true + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "qu_000000000000", + "expires_at": 1712958191, + "commercial_quotation": 1.0, + "blindpay_quotation": 1.0, + "receiver_amount": 1000, + "sender_amount": 1000, + "partner_fee_amount": null, + "flat_fee": 0 + }))) + .mount(&server) + .await; + + let input = CreateTransferQuoteInput::new( + "bl_000000000000", + CurrencyType::Sender, + 1000, + Token::Usdc, + "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + Token::Usdc, + Network::Polygon, + ) + .cover_fees(true); + + let quote = client(&server) + .await + .transfers + .quotes + .create(&input) + .await + .unwrap(); + + assert_eq!(quote.id, "qu_000000000000"); + assert_eq!(quote.sender_amount, 1000); + assert_eq!(quote.receiver_amount, 1000); + assert_eq!(quote.flat_fee, 0); + assert_eq!(quote.expires_at, Some(1712958191)); + assert_eq!(quote.partner_fee_amount, None); +} + +#[tokio::test] +async fn create_sends_quote_id_and_parses_tracking() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/transfers")) + .and(header("authorization", "Bearer test-api-key")) + .and(body_json( + serde_json::json!({ "transfer_quote_id": "qu_000000000000" }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "tr_000000000000", + "status": "processing", + "tracking_bridge_swap": tracking_step(), + "tracking_complete": tracking_step(), + "tracking_paymaster": tracking_step(), + "tracking_transaction_monitoring": tracking_monitoring(), + "tracking_partner_fee": tracking_step() + }))) + .mount(&server) + .await; + + let created = client(&server) + .await + .transfers + .create(&CreateTransferInput::new("qu_000000000000")) + .await + .unwrap(); + + assert_eq!(created.id, "tr_000000000000"); + assert_eq!(created.status, TransactionStatus::Processing); + assert_eq!(created.tracking_complete.step, TrackingStatus::Processing); +} + +#[tokio::test] +async fn list_sends_pagination_and_parses_paginated_envelope() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/transfers")) + .and(query_param("limit", "50")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [transfer_body()], + "pagination": { + "has_more": true, + "next_page": "tr_000000000000", + "prev_page": null + } + }))) + .mount(&server) + .await; + + let params = PaginationParams::new().limit(blindpay::Limit::Fifty); + let resp = client(&server).await.transfers.list(¶ms).await.unwrap(); + + assert_eq!(resp.data().len(), 1); + assert_eq!(resp.data()[0].id, "tr_000000000000"); + assert_eq!(resp.data()[0].sender_token, Token::Usdc); + assert_eq!(resp.data()[0].receiver_network, Network::Polygon); + assert_eq!(resp.data()[0].first_name.as_deref(), Some("John")); + assert!(resp.pagination().unwrap().has_more); + assert_eq!( + resp.pagination().unwrap().next_page.as_deref(), + Some("tr_000000000000") + ); +} + +#[tokio::test] +async fn get_parses_full_transfer() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/transfers/tr_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(transfer_body())) + .mount(&server) + .await; + + let transfer = client(&server) + .await + .transfers + .get("tr_000000000000") + .await + .unwrap(); + + assert_eq!(transfer.id, "tr_000000000000"); + assert_eq!(transfer.status, TransactionStatus::Processing); + assert_eq!(transfer.transfer_quote_id, "qu_000000000000"); + assert_eq!(transfer.receiver_id, "re_000000000000"); + assert_eq!(transfer.external_id.as_deref(), Some("ext_1")); + assert_eq!(transfer.partner_fee_amount, None); + assert_eq!( + transfer.tracking_transaction_monitoring.step, + TrackingStatus::Processing + ); +} + +#[tokio::test] +async fn get_track_uses_unauth_path() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v1/e/transfers/tr_000000000000")) + .respond_with(ResponseTemplate::new(200).set_body_json(transfer_body())) + .mount(&server) + .await; + + let transfer = client(&server) + .await + .transfers + .get_track("tr_000000000000") + .await + .unwrap(); + + assert_eq!(transfer.id, "tr_000000000000"); +} + +#[tokio::test] +async fn empty_id_fails_before_any_request() { + let client = BlindPay::new("test-api-key", "in_test").unwrap(); + let err = client.transfers.get(" ").await.unwrap_err(); + assert!(matches!(err, Error::Config(_))); + + let err = client.transfers.get_track("").await.unwrap_err(); + assert!(matches!(err, Error::Config(_))); +} + +#[tokio::test] +async fn create_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/transfers")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "transfer_quote_expired" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .transfers + .create(&CreateTransferInput::new("qu_000000000000")) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "transfer_quote_expired"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn quotes_create_non_success_returns_api_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/transfer-quotes")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "kyc_not_approved" })), + ) + .mount(&server) + .await; + + let input = CreateTransferQuoteInput::new( + "bl_000000000000", + CurrencyType::Sender, + 1000, + Token::Usdc, + "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + Token::Usdc, + Network::Polygon, + ); + + let err = client(&server) + .await + .transfers + .quotes + .create(&input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "kyc_not_approved"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/upload.rs b/tests/upload.rs new file mode 100644 index 0000000..6f8b5bf --- /dev/null +++ b/tests/upload.rs @@ -0,0 +1,104 @@ +//! Integration tests for the `upload` resource, backed by a mock HTTP server. + +use blindpay::{BlindPay, Error, UploadBucket}; +use wiremock::matchers::{header, header_regex, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Builds a client pointed at the mock server. The mock's `/v1` prefix mirrors +/// the production base URL's path. +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +#[tokio::test] +async fn upload_sends_multipart_and_parses_file_url() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("POST")) + .and(path("/v1/upload")) + .and(query_param("instance_id", "in_test")) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "file_url": "https://storage.blindpay.com/uploads/abc123.pdf" + }))) + .mount(&server) + .await; + + let response = client(&server) + .await + .upload + .upload( + "document.pdf", + b"test content".to_vec(), + UploadBucket::Onboarding, + ) + .await + .unwrap(); + + assert_eq!( + response.file_url, + "https://storage.blindpay.com/uploads/abc123.pdf" + ); +} + +#[tokio::test] +async fn upload_does_not_set_json_content_type() { + let server = MockServer::start().await; + + // reqwest sets `multipart/form-data; boundary=...` for a multipart body; if + // the transport wrongly forced `application/json`, this matcher would miss. + Mock::given(method("POST")) + .and(path("/v1/upload")) + .and(header_regex("content-type", "^multipart/form-data")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "file_url": "https://storage.blindpay.com/uploads/avatar.png" + }))) + .mount(&server) + .await; + + let response = client(&server) + .await + .upload + .upload("avatar.png", b"\x89PNG".to_vec(), UploadBucket::Avatar) + .await + .unwrap(); + + assert_eq!( + response.file_url, + "https://storage.blindpay.com/uploads/avatar.png" + ); +} + +#[tokio::test] +async fn upload_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/upload")) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "invalid file" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .upload + .upload("bad.pdf", b"oops".to_vec(), UploadBucket::LimitIncrease) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid file"); + assert!(api.raw_body.contains("invalid file")); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/virtual_accounts.rs b/tests/virtual_accounts.rs new file mode 100644 index 0000000..f28ac8d --- /dev/null +++ b/tests/virtual_accounts.rs @@ -0,0 +1,312 @@ +//! Integration tests for the `virtual_accounts` resource, backed by a mock +//! HTTP server. + +use blindpay::{ + BankingPartner, BlindPay, CreateVirtualAccountInput, Error, KycStatus, Network, Token, + UpdateVirtualAccountInput, +}; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +fn virtual_account_json() -> serde_json::Value { + serde_json::json!({ + "id": "va_123", + "banking_partner": "jpmorgan", + "kyc_status": "approved", + "us": { + "ach": { "routing_number": "021000021", "account_number": "111111111" }, + "wire": { "routing_number": "021000021", "account_number": "222222222" }, + "rtp": { "routing_number": "021000021", "account_number": "333333333" }, + "swift_bic_code": "TCCLGB3L", + "swift_account_number": "GB50TCCL04140449730892", + "account_type": "Business checking", + "beneficiary": { + "name": "Test Co", + "address_line_1": "8 The Green, #19364", + "address_line_2": "Dover, DE 19901" + }, + "receiving_bank": { + "name": "JPMorgan Chase", + "address_line_1": "270 Park Ave", + "address_line_2": "New York, NY, 10017-2070" + }, + "swift_intermediary_bank": { + "name": "JP Morgan Chase NA", + "swift_code_bic": "CHASUS33", + "routing_number": "021000021" + } + }, + "token": "USDC", + "blockchain_wallet_id": "bw_123", + "blockchain_wallet": { "network": "base", "address": "0xabc" } + }) +} + +#[tokio::test] +async fn list_parses_response_and_sends_auth_headers() { + let server = MockServer::start().await; + let user_agent = format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")); + + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts", + )) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent.as_str())) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!([virtual_account_json()])), + ) + .mount(&server) + .await; + + let accounts = client(&server) + .await + .virtual_accounts + .list("cus_123") + .await + .unwrap(); + + assert_eq!(accounts.len(), 1); + let va = &accounts[0]; + assert_eq!(va.id, "va_123"); + assert_eq!(va.banking_partner, BankingPartner::Jpmorgan); + assert_eq!(va.kyc_status, KycStatus::Approved); + assert_eq!(va.token, Token::Usdc); + assert_eq!(va.us.wire.account_number, "222222222"); + assert!(va.us.ach.is_some()); + assert_eq!(va.us.swift_bic_code.as_deref(), Some("TCCLGB3L")); + assert_eq!( + va.blockchain_wallet.as_ref().unwrap().network, + Network::Base + ); +} + +#[tokio::test] +async fn get_parses_single_account() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts/va_123", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(virtual_account_json())) + .mount(&server) + .await; + + let account = client(&server) + .await + .virtual_accounts + .get("cus_123", "va_123") + .await + .unwrap(); + + let va = account.expect("expected Some(VirtualAccount)"); + assert_eq!(va.id, "va_123"); + assert_eq!(va.blockchain_wallet_id.as_deref(), Some("bw_123")); +} + +#[tokio::test] +async fn get_returns_none_for_json_null() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts/va_missing", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::Value::Null)) + .mount(&server) + .await; + + let account = client(&server) + .await + .virtual_accounts + .get("cus_123", "va_missing") + .await + .unwrap(); + + assert!(account.is_none()); +} + +#[tokio::test] +async fn get_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts/va_missing", + )) + .respond_with( + ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "message": "virtual_account_not_found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .virtual_accounts + .get("cus_123", "va_missing") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "virtual_account_not_found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn create_sends_body_and_parses_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts", + )) + .and(header("authorization", "Bearer test-api-key")) + .and(body_json(serde_json::json!({ + "banking_partner": "jpmorgan", + "token": "USDC", + "blockchain_wallet_id": "bw_123" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(virtual_account_json())) + .mount(&server) + .await; + + let input = CreateVirtualAccountInput::new(BankingPartner::Jpmorgan, Token::Usdc, "bw_123"); + let va = client(&server) + .await + .virtual_accounts + .create("cus_123", &input) + .await + .unwrap(); + + assert_eq!(va.id, "va_123"); + assert_eq!(va.banking_partner, BankingPartner::Jpmorgan); +} + +#[tokio::test] +async fn update_sends_put_body_and_parses_success() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts/va_123", + )) + .and(body_json(serde_json::json!({ + "token": "USDC", + "blockchain_wallet_id": "bw_456" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true + }))) + .mount(&server) + .await; + + let input = UpdateVirtualAccountInput::new(Token::Usdc, "bw_456"); + let result = client(&server) + .await + .virtual_accounts + .update("cus_123", "va_123", &input) + .await + .unwrap(); + + assert!(result.success); +} + +#[tokio::test] +async fn list_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts", + )) + .respond_with( + ResponseTemplate::new(403) + .set_body_json(serde_json::json!({ "message": "user_not_allowed" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .virtual_accounts + .list("cus_123") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 403); + assert_eq!(api.message, "user_not_allowed"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn create_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts", + )) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "invalid_banking_partner" })), + ) + .mount(&server) + .await; + + let input = CreateVirtualAccountInput::new(BankingPartner::Citi, Token::Usdc, "bw_123"); + let err = client(&server) + .await + .virtual_accounts + .create("cus_123", &input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "invalid_banking_partner"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +#[tokio::test] +async fn update_not_found_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path( + "/v1/instances/in_test/customers/cus_123/virtual-accounts/va_missing", + )) + .respond_with( + ResponseTemplate::new(404) + .set_body_json(serde_json::json!({ "message": "virtual_account_not_found" })), + ) + .mount(&server) + .await; + + let input = UpdateVirtualAccountInput::new(Token::Usdc, "bw_456"); + let err = client(&server) + .await + .virtual_accounts + .update("cus_123", "va_missing", &input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "virtual_account_not_found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} diff --git a/tests/wallets.rs b/tests/wallets.rs new file mode 100644 index 0000000..849d5a6 --- /dev/null +++ b/tests/wallets.rs @@ -0,0 +1,818 @@ +//! Integration tests for the `wallets` resources (blockchain, custodial, and +//! offramp), backed by a mock HTTP server. + +use blindpay::{ + BlindPay, CreateBlockchainWalletWithAddressInput, CreateBlockchainWalletWithHashInput, + CreateOfframpWalletInput, CreateWalletInput, Error, MintUsdbSolanaInput, MintUsdbStellarInput, + Network, PrepareSolanaDelegationInput, Token, +}; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn client(server: &MockServer) -> BlindPay { + BlindPay::builder("test-api-key", "in_test") + .base_url(format!("{}/v1", server.uri())) + .build() + .expect("client should build") +} + +fn user_agent() -> String { + format!("blindpay-rust/{}", env!("CARGO_PKG_VERSION")) +} + +// --------------------------------------------------------------------------- +// blockchain wallets +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn blockchain_list_parses_and_sends_auth_headers() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets", + )) + .and(header("authorization", "Bearer test-api-key")) + .and(header("user-agent", user_agent().as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "bw_000000000000", + "name": "My Wallet", + "network": "polygon", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "is_account_abstraction": true, + "receiver_id": "re_000000000000" + } + ]))) + .mount(&server) + .await; + + let wallets = client(&server) + .await + .wallets + .blockchain + .list("cus_123") + .await + .unwrap(); + + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].id, "bw_000000000000"); + assert_eq!(wallets[0].network, Network::Polygon); + assert!(wallets[0].is_account_abstraction); + assert_eq!(wallets[0].receiver_id, "re_000000000000"); + assert!(wallets[0].signature_tx_hash.is_none()); +} + +#[tokio::test] +async fn blockchain_get_wallet_message_parses_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets/sign-message", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "message": "sign this nonce" })), + ) + .mount(&server) + .await; + + let msg = client(&server) + .await + .wallets + .blockchain + .get_wallet_message("cus_123") + .await + .unwrap(); + + assert_eq!(msg.message, "sign this nonce"); +} + +#[tokio::test] +async fn blockchain_get_parses_single_wallet() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets/bw_000000000000", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bw_000000000000", + "name": "Sig Wallet", + "network": "base", + "signature_tx_hash": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "is_account_abstraction": false, + "receiver_id": "re_000000000000" + }))) + .mount(&server) + .await; + + let wallet = client(&server) + .await + .wallets + .blockchain + .get("cus_123", "bw_000000000000") + .await + .unwrap(); + + assert_eq!(wallet.network, Network::Base); + assert!(!wallet.is_account_abstraction); + assert_eq!( + wallet.signature_tx_hash.as_deref(), + Some("0x3c499c542cef5e3811e1192ce70d8cc03d5c3359") + ); + assert!(wallet.address.is_none()); +} + +#[tokio::test] +async fn blockchain_create_with_address_sends_true_discriminator() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets", + )) + .and(body_json(serde_json::json!({ + "name": "My Wallet", + "network": "polygon", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "is_account_abstraction": true + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bw_000000000000", + "name": "My Wallet", + "network": "polygon", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "is_account_abstraction": true, + "receiver_id": "re_000000000000" + }))) + .mount(&server) + .await; + + let input = CreateBlockchainWalletWithAddressInput { + name: "My Wallet".to_string(), + network: Network::Polygon, + address: "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C".to_string(), + is_account_abstraction: true, + }; + let wallet = client(&server) + .await + .wallets + .blockchain + .create_with_address("cus_123", &input) + .await + .unwrap(); + + assert_eq!(wallet.id, "bw_000000000000"); + assert!(wallet.is_account_abstraction); +} + +#[tokio::test] +async fn blockchain_create_with_hash_sends_false_discriminator() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets", + )) + .and(body_json(serde_json::json!({ + "name": "Sig Wallet", + "network": "base", + "signature_tx_hash": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "is_account_abstraction": false + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bw_000000000001", + "name": "Sig Wallet", + "network": "base", + "signature_tx_hash": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "is_account_abstraction": false, + "receiver_id": "re_000000000000" + }))) + .mount(&server) + .await; + + let input = CreateBlockchainWalletWithHashInput { + name: "Sig Wallet".to_string(), + network: Network::Base, + signature_tx_hash: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359".to_string(), + is_account_abstraction: false, + }; + let wallet = client(&server) + .await + .wallets + .blockchain + .create_with_hash("cus_123", &input) + .await + .unwrap(); + + assert_eq!(wallet.id, "bw_000000000001"); + assert!(!wallet.is_account_abstraction); +} + +#[tokio::test] +async fn blockchain_delete_returns_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets/bw_000000000000", + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .blockchain + .delete("cus_123", "bw_000000000000") + .await + .unwrap(); + + assert!(res.success); +} + +#[tokio::test] +async fn blockchain_list_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/blockchain-wallets", + )) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({ "message": "not found" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .wallets + .blockchain + .list("cus_123") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 404); + assert_eq!(api.message, "not found"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// custodial wallets +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn custodial_list_parses_wallets() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/instances/in_test/customers/cus_123/wallets")) + .and(header("authorization", "Bearer test-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "bl_000000000000", + "name": "Blindpay Wallet", + "external_id": "your-database-id", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C", + "network": "polygon", + "created_at": "2026-01-01T00:00:00.000Z" + } + ]))) + .mount(&server) + .await; + + let wallets = client(&server) + .await + .wallets + .custodial + .list("cus_123") + .await + .unwrap(); + + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].id, "bl_000000000000"); + assert_eq!(wallets[0].external_id.as_deref(), Some("your-database-id")); + assert_eq!(wallets[0].network, Network::Polygon); +} + +#[tokio::test] +async fn custodial_get_parses_wallet_with_null_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/wallets/bl_000000000000", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bl_000000000000", + "name": "Blindpay Wallet", + "external_id": null, + "address": null, + "network": "base", + "created_at": "2026-01-01T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let wallet = client(&server) + .await + .wallets + .custodial + .get("cus_123", "bl_000000000000") + .await + .unwrap(); + + assert!(wallet.external_id.is_none()); + assert!(wallet.address.is_none()); + assert_eq!(wallet.network, Network::Base); +} + +#[tokio::test] +async fn custodial_get_balance_parses_token_map() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/wallets/bl_000000000000/balance", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "USDC": { "address": "0xabc", "id": "tok_1", "symbol": "USDC", "amount": 1000 }, + "USDT": { "address": "0xdef", "id": "tok_2", "symbol": "USDT", "amount": 0 }, + "USDB": { "address": "0xghi", "id": "tok_3", "symbol": "USDB", "amount": 25.5 } + }))) + .mount(&server) + .await; + + let balance = client(&server) + .await + .wallets + .custodial + .get_balance("cus_123", "bl_000000000000") + .await + .unwrap(); + + assert_eq!(balance.usdc.symbol, Token::Usdc); + assert_eq!(balance.usdc.amount, 1000.0); + assert_eq!(balance.usdt.amount, 0.0); + assert_eq!(balance.usdb.amount, 25.5); + assert_eq!(balance.usdb.id, "tok_3"); +} + +#[tokio::test] +async fn custodial_create_sends_body_and_parses_wallet() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/cus_123/wallets")) + .and(body_json(serde_json::json!({ + "network": "polygon", + "name": "My Custodial Wallet", + "external_id": "ext_123" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bl_000000000000", + "name": "My Custodial Wallet", + "external_id": "ext_123", + "address": null, + "network": "polygon", + "created_at": "2026-01-01T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let input = CreateWalletInput { + network: Network::Polygon, + name: "My Custodial Wallet".to_string(), + external_id: Some("ext_123".to_string()), + }; + let wallet = client(&server) + .await + .wallets + .custodial + .create("cus_123", &input) + .await + .unwrap(); + + assert_eq!(wallet.id, "bl_000000000000"); + assert_eq!(wallet.external_id.as_deref(), Some("ext_123")); +} + +#[tokio::test] +async fn custodial_create_omits_unset_external_id() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/customers/cus_123/wallets")) + .and(body_json(serde_json::json!({ + "network": "base", + "name": "No External" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "bl_000000000001", + "name": "No External", + "external_id": null, + "address": null, + "network": "base", + "created_at": "2026-01-01T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let input = CreateWalletInput { + network: Network::Base, + name: "No External".to_string(), + external_id: None, + }; + let wallet = client(&server) + .await + .wallets + .custodial + .create("cus_123", &input) + .await + .unwrap(); + + assert_eq!(wallet.id, "bl_000000000001"); +} + +#[tokio::test] +async fn custodial_delete_returns_success() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path( + "/v1/instances/in_test/customers/cus_123/wallets/bl_000000000000", + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .custodial + .delete("cus_123", "bl_000000000000") + .await + .unwrap(); + + assert!(res.success); +} + +#[tokio::test] +async fn custodial_get_balance_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/wallets/bl_000000000000/balance", + )) + .respond_with( + ResponseTemplate::new(500) + .set_body_json(serde_json::json!({ "message": "balance unavailable" })), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .wallets + .custodial + .get_balance("cus_123", "bl_000000000000") + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 500); + assert_eq!(api.message, "balance unavailable"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// offramp wallets +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn offramp_list_parses_full_wallets() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/bank-accounts/ba_123/offramp-wallets", + )) + .and(header("authorization", "Bearer test-api-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + { + "id": "ow_000000000000", + "external_id": "your_external_id", + "instance_id": "in_test", + "receiver_id": "re_000000000000", + "bank_account_id": "ba_123", + "circle_wallet_id": "01234567-abcd-efgh-ijkl-012345678901", + "network": "tron", + "address": "TALJN9zTTEL9TVBb4WuTt6wLvPqJZr3hvb", + "created_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-01-02T00:00:00.000Z" + } + ]))) + .mount(&server) + .await; + + let wallets = client(&server) + .await + .wallets + .offramp + .list("cus_123", "ba_123") + .await + .unwrap(); + + assert_eq!(wallets.len(), 1); + assert_eq!(wallets[0].id, "ow_000000000000"); + assert_eq!(wallets[0].network, Network::Tron); + assert_eq!(wallets[0].bank_account_id.as_deref(), Some("ba_123")); + assert_eq!(wallets[0].receiver_id.as_deref(), Some("re_000000000000")); +} + +#[tokio::test] +async fn offramp_create_sends_body_and_parses_create_shape() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/bank-accounts/ba_123/offramp-wallets", + )) + .and(body_json(serde_json::json!({ + "external_id": "ext_456", + "network": "base" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ow_000000000001", + "external_id": "ext_456", + "circle_wallet_id": null, + "network": "base", + "address": "0xDD6a3aD0949396e57C7738ba8FC1A46A5a1C372C" + }))) + .mount(&server) + .await; + + let input = CreateOfframpWalletInput { + external_id: Some("ext_456".to_string()), + network: Network::Base, + }; + let wallet = client(&server) + .await + .wallets + .offramp + .create("cus_123", "ba_123", &input) + .await + .unwrap(); + + assert_eq!(wallet.id, "ow_000000000001"); + assert_eq!(wallet.network, Network::Base); + // The create response omits these fields, so they default to None. + assert!(wallet.bank_account_id.is_none()); + assert!(wallet.created_at.is_none()); +} + +#[tokio::test] +async fn offramp_get_parses_wallet() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/v1/instances/in_test/customers/cus_123/bank-accounts/ba_123/offramp-wallets/ow_000000000000", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "ow_000000000000", + "external_id": null, + "instance_id": "in_test", + "receiver_id": "re_000000000000", + "bank_account_id": "ba_123", + "circle_wallet_id": null, + "network": "solana", + "address": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", + "created_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-01-01T00:00:00.000Z" + }))) + .mount(&server) + .await; + + let wallet = client(&server) + .await + .wallets + .offramp + .get("cus_123", "ba_123", "ow_000000000000") + .await + .unwrap(); + + assert_eq!(wallet.id, "ow_000000000000"); + assert_eq!(wallet.network, Network::Solana); + assert!(wallet.external_id.is_none()); +} + +#[tokio::test] +async fn offramp_create_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/v1/instances/in_test/customers/cus_123/bank-accounts/ba_123/offramp-wallets", + )) + .respond_with( + ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "message": "unsupported network" })), + ) + .mount(&server) + .await; + + let input = CreateOfframpWalletInput { + external_id: None, + network: Network::Stellar, + }; + let err = client(&server) + .await + .wallets + .offramp + .create("cus_123", "ba_123", &input) + .await + .unwrap_err(); + + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "unsupported network"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// blockchain chain ops (Stellar trustline, USDB mint, Solana delegation) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_asset_trustline_sends_address_and_parses_xdr() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/create-asset-trustline")) + .and(header("authorization", "Bearer test-api-key")) + .and(body_json(serde_json::json!({ "address": "GABC123" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true, + "xdr": "AAAAencoded" + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .blockchain + .create_asset_trustline("GABC123") + .await + .unwrap(); + assert_eq!(res.xdr, "AAAAencoded"); +} + +#[tokio::test] +async fn mint_usdb_stellar_omits_unset_signed_xdr() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/mint-usdb-stellar")) + .and(body_json(serde_json::json!({ + "address": "GABC123", + "amount": "100" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .blockchain + .mint_usdb_stellar(&MintUsdbStellarInput { + address: "GABC123".to_string(), + amount: "100".to_string(), + signed_xdr: None, + }) + .await + .unwrap(); + assert!(res.success); +} + +#[tokio::test] +async fn mint_usdb_stellar_serializes_signed_xdr_as_camel_case() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/mint-usdb-stellar")) + .and(body_json(serde_json::json!({ + "address": "GABC123", + "amount": "100", + "signedXdr": "signed" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "success": true })), + ) + .mount(&server) + .await; + + client(&server) + .await + .wallets + .blockchain + .mint_usdb_stellar(&MintUsdbStellarInput { + address: "GABC123".to_string(), + amount: "100".to_string(), + signed_xdr: Some("signed".to_string()), + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn mint_usdb_solana_parses_signature() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/mint-usdb-solana")) + .and(body_json( + serde_json::json!({ "address": "So1ABC", "amount": "100" }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true, + "signature": "sig_abc" + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .blockchain + .mint_usdb_solana(&MintUsdbSolanaInput { + address: "So1ABC".to_string(), + amount: "100".to_string(), + }) + .await + .unwrap(); + assert!(res.success); + assert_eq!(res.signature.as_deref(), Some("sig_abc")); +} + +#[tokio::test] +async fn prepare_solana_delegation_omits_unset_fields_and_parses_transaction() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/prepare-delegate-solana")) + .and(body_json(serde_json::json!({ + "owner_address": "So1ABC", + "quote_id": "qt_123456789012" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "success": true, + "transaction": "base64tx" + }))) + .mount(&server) + .await; + + let res = client(&server) + .await + .wallets + .blockchain + .prepare_solana_delegation_transaction(&PrepareSolanaDelegationInput { + owner_address: "So1ABC".to_string(), + quote_id: Some("qt_123456789012".to_string()), + token_address: None, + amount: None, + }) + .await + .unwrap(); + assert!(res.success); + assert_eq!(res.transaction.as_deref(), Some("base64tx")); +} + +#[tokio::test] +async fn create_asset_trustline_non_success_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/instances/in_test/create-asset-trustline")) + .respond_with( + ResponseTemplate::new(400).set_body_json( + serde_json::json!({ "message": "blockchain_wallet_address_missing" }), + ), + ) + .mount(&server) + .await; + + let err = client(&server) + .await + .wallets + .blockchain + .create_asset_trustline("GABC123") + .await + .unwrap_err(); + match err { + Error::Api(api) => { + assert_eq!(api.status, 400); + assert_eq!(api.message, "blockchain_wallet_address_missing"); + } + other => panic!("expected Error::Api, got {other:?}"), + } +}