From 40481124612a73a9501d21e80034962b93e3542d Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Sun, 19 Jul 2026 12:14:37 -0700 Subject: [PATCH] feat(settlement): production Revolut, Wise, and Binance external rails Wire Business/Platform/Spot adapters with fail-closed live gates, status/probe/dispatch APIs, smoke script, and portal rails UI. Co-authored-by: Cursor --- .gitignore | 5 + .../zardasht-waisi-amin.zbank.v1.json | 1698 ++++++++++++++++- config/rails-revolut-wise-binance.env.example | 31 + .../rails-revolut-wise-binance.env.example | 31 + packages/settlement-core/dist/types.d.ts | 7 +- packages/settlement-core/src/types.ts | 10 +- scripts/rails/smoke-revolut-wise-binance.mjs | 147 ++ .../dist/adapters/binance-exchange.js | 114 ++ .../dist/adapters/external-banks-hub.js | 137 ++ .../dist/adapters/revolut-business.js | 111 ++ .../dist/adapters/wise-platform.js | 142 ++ .../dist/api/routes/settlement.js | 95 +- services/settlement-middleware/dist/index.js | 12 + .../dist/workflows/transfer.js | 37 +- .../src/adapters/binance-exchange.ts | 126 ++ .../src/adapters/external-banks-hub.ts | 165 ++ .../src/adapters/revolut-business.ts | 133 ++ .../src/adapters/wise-platform.ts | 171 ++ .../src/api/routes/settlement.ts | 103 +- services/settlement-middleware/src/index.ts | 12 + .../src/workflows/transfer.ts | 41 +- .../public/rails-revolut-wise-binance.html | 69 + .../public/zbank-online-cards.html | 100 +- services/token-aggregation/src/api/server.ts | 40 +- .../src/lib/zbank-online-cards.ts | 99 +- 25 files changed, 3575 insertions(+), 61 deletions(-) create mode 100644 config/rails-revolut-wise-binance.env.example create mode 100644 config/secrets-templates/rails-revolut-wise-binance.env.example create mode 100644 scripts/rails/smoke-revolut-wise-binance.mjs create mode 100644 services/settlement-middleware/dist/adapters/binance-exchange.js create mode 100644 services/settlement-middleware/dist/adapters/external-banks-hub.js create mode 100644 services/settlement-middleware/dist/adapters/revolut-business.js create mode 100644 services/settlement-middleware/dist/adapters/wise-platform.js create mode 100644 services/settlement-middleware/src/adapters/binance-exchange.ts create mode 100644 services/settlement-middleware/src/adapters/external-banks-hub.ts create mode 100644 services/settlement-middleware/src/adapters/revolut-business.ts create mode 100644 services/settlement-middleware/src/adapters/wise-platform.ts create mode 100644 services/token-aggregation/public/rails-revolut-wise-binance.html diff --git a/.gitignore b/.gitignore index cc507cf..ca160ab 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,8 @@ lib/dodo-gassaving-pool/ lib/dodo-limit-order/ lib/dodo-v3/ lib/deploy-starter.txt + +# External rail credentials (keep *.example tracked if forced) +secrets/**/credentials.env +!secrets/**/credentials.env.example + diff --git a/config/customers/zardasht-waisi-amin.zbank.v1.json b/config/customers/zardasht-waisi-amin.zbank.v1.json index 04465ce..e9abb37 100644 --- a/config/customers/zardasht-waisi-amin.zbank.v1.json +++ b/config/customers/zardasht-waisi-amin.zbank.v1.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "asOf": "2026-07-19T17:55:35.143Z", + "asOf": "2026-07-19T18:57:13.026Z", "bank": { "name": "Z Online Bank", "officeId": 29, @@ -269,18 +269,487 @@ "note": "Online CNP spend on linked IBAN balance" } } + }, + { + "id": "zcard_mrs5ow4u_5453", + "brand": "Visa", + "product": "Hermes Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******0195", + "panToken": "tok_aaf6e259f3d41d5c56e7f8ad", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.583Z", + "exchange": { + "id": "hermes", + "name": "Hermes Desk", + "officeId": 32, + "theme": "hermes", + "bicHint": "HRMSOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Hermes Desk co-brand · BIC HRMSOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrll_6810", + "brand": "Visa", + "product": "Hermes Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******6559", + "panToken": "tok_e825bbd2fa62d18ca75e96ab", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.017Z", + "exchange": { + "id": "hermes", + "name": "Hermes Desk", + "officeId": 32, + "theme": "hermes", + "bicHint": "HRMSOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Hermes Desk co-brand · BIC HRMSOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrln_3155", + "brand": "Visa", + "product": "HYBX Rail Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******8414", + "panToken": "tok_223f296051b822d9d45e74b8", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.019Z", + "exchange": { + "id": "hybx", + "name": "HYBX Rail", + "officeId": 3, + "theme": "hybx", + "bicHint": "HYBXOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "HYBX Rail co-brand · BIC HYBXOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrlq_5628", + "brand": "Visa", + "product": "Tsunami Exchange Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******3846", + "panToken": "tok_28b4829974424c190de7298f", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.022Z", + "exchange": { + "id": "tsunami", + "name": "Tsunami Exchange", + "officeId": 15, + "theme": "tsunami", + "bicHint": "TSUNOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Tsunami Exchange co-brand · BIC TSUNOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrlr_3829", + "brand": "Visa", + "product": "OMNL Forex Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******7929", + "panToken": "tok_58d88d0e32020d1252b790be", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.023Z", + "exchange": { + "id": "omnl-fx", + "name": "OMNL Forex Desk", + "officeId": 1, + "theme": "omnl", + "bicHint": "OMNLUS33XXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OMNL Forex Desk co-brand · BIC OMNLUS33XXX" + } + } + }, + { + "id": "zcard_mrs5qrls_9837", + "brand": "Visa", + "product": "OneX Exchange Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******4715", + "panToken": "tok_008ad31961a302c3a2620e85", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.024Z", + "exchange": { + "id": "onex", + "name": "OneX Exchange", + "officeId": null, + "theme": "onex", + "bicHint": "ONEXSHIV33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OneX Exchange co-brand · BIC ONEXSHIV33" + } + } + }, + { + "id": "zcard_mrs5qrlu_9021", + "brand": "Visa", + "product": "AnakaBank Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******5583", + "panToken": "tok_584d20e3cd5a9a3bf30bbb47", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.026Z", + "exchange": { + "id": "anakabank", + "name": "AnakaBank", + "officeId": 31, + "theme": "anaka", + "bicHint": "ANAKOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "AnakaBank co-brand · BIC ANAKOMNL33" + } + } } ], "onlineCard": { - "id": "zcard_mrs3ji8l_6109", + "id": "zcard_mrs5qrlu_9021", "brand": "Visa", - "product": "Z Online Card", + "product": "AnakaBank Visa", "formFactor": "online", "currency": "USD", "linkedIban": "LU890010000063089842", "linkedAccountNo": "000000001", - "panMasked": "400005******8371", - "panToken": "tok_8f114918cde825a53e76a911", + "panMasked": "400005******5583", + "panToken": "tok_584d20e3cd5a9a3bf30bbb47", "expiry": "07/30", "nameOnCard": "ZARDASHT WAISI AMIN", "status": "ACTIVE", @@ -288,7 +757,21 @@ "onlineOnly": true, "atmEnabled": true, "contactless": true, - "createdAt": "2026-07-19T17:55:35.063Z", + "createdAt": "2026-07-19T18:57:13.026Z", + "exchange": { + "id": "anakabank", + "name": "AnakaBank", + "officeId": 31, + "theme": "anaka", + "bicHint": "ANAKOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, "networks": { "visa": { "status": "ISSUED_ONLINE", @@ -317,6 +800,10 @@ "ecommerce": { "status": "ACTIVE", "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "AnakaBank co-brand · BIC ANAKOMNL33" } } } @@ -471,18 +958,219 @@ "note": "Online CNP spend on linked IBAN balance" } } + }, + { + "id": "zcard_mrs5ow4w_6705", + "brand": "Visa", + "product": "OneX Exchange Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******4973", + "panToken": "tok_9f951ee8d4353d83ee5c4262", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.584Z", + "exchange": { + "id": "onex", + "name": "OneX Exchange", + "officeId": null, + "theme": "onex", + "bicHint": "ONEXSHIV33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OneX Exchange co-brand · BIC ONEXSHIV33" + } + } + }, + { + "id": "zcard_mrs5ow4x_7971", + "brand": "Visa", + "product": "NovaBank Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******6675", + "panToken": "tok_4ef70b9a948fcdded219705e", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.585Z", + "exchange": { + "id": "novabank", + "name": "NovaBank", + "officeId": 30, + "theme": "nova", + "bicHint": "NOVAGB2LXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "NovaBank co-brand · BIC NOVAGB2LXXX" + } + } + }, + { + "id": "zcard_mrs5ow4x_4643", + "brand": "Visa", + "product": "HYBX Rail Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******8729", + "panToken": "tok_fc8224d3f7502a5e41f2e342", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.585Z", + "exchange": { + "id": "hybx", + "name": "HYBX Rail", + "officeId": 3, + "theme": "hybx", + "bicHint": "HYBXOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "HYBX Rail co-brand · BIC HYBXOMNL33" + } + } } ], "onlineCard": { - "id": "zcard_mrs3jiao_7411", + "id": "zcard_mrs5ow4x_4643", "brand": "Visa", - "product": "Z Online Card", + "product": "HYBX Rail Visa", "formFactor": "online", "currency": "EUR", "linkedIban": "DE73500105173775892661", "linkedAccountNo": "000000002", - "panMasked": "400005******8745", - "panToken": "tok_945a6c603e918c6f8c20ff17", + "panMasked": "400005******8729", + "panToken": "tok_fc8224d3f7502a5e41f2e342", "expiry": "07/30", "nameOnCard": "ZARDASHT WAISI AMIN", "status": "ACTIVE", @@ -490,7 +1178,21 @@ "onlineOnly": true, "atmEnabled": true, "contactless": true, - "createdAt": "2026-07-19T17:55:35.136Z", + "createdAt": "2026-07-19T18:55:45.585Z", + "exchange": { + "id": "hybx", + "name": "HYBX Rail", + "officeId": 3, + "theme": "hybx", + "bicHint": "HYBXOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, "networks": { "visa": { "status": "ISSUED_ONLINE", @@ -519,6 +1221,10 @@ "ecommerce": { "status": "ACTIVE", "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "HYBX Rail co-brand · BIC HYBXOMNL33" } } } @@ -673,18 +1379,152 @@ "note": "Online CNP spend on linked IBAN balance" } } + }, + { + "id": "zcard_mrs5qrlp_3960", + "brand": "Visa", + "product": "NovaBank Visa", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******9479", + "panToken": "tok_e775024eda93d6e188ccda43", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.021Z", + "exchange": { + "id": "novabank", + "name": "NovaBank", + "officeId": 30, + "theme": "nova", + "bicHint": "NOVAGB2LXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "NovaBank co-brand · BIC NOVAGB2LXXX" + } + } + }, + { + "id": "zcard_mrs5qrlt_7265", + "brand": "Visa", + "product": "Z Online Bank Visa", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******1654", + "panToken": "tok_5e072ba925d6c16efeb06cc9", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.025Z", + "exchange": { + "id": "zbank", + "name": "Z Online Bank", + "officeId": 29, + "theme": "zbank", + "bicHint": "ZBKNOMNLXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Z Online Bank co-brand · BIC ZBKNOMNLXXX" + } + } } ], "onlineCard": { - "id": "zcard_mrs3jiav_8336", + "id": "zcard_mrs5qrlt_7265", "brand": "Visa", - "product": "Z Online Card", + "product": "Z Online Bank Visa", "formFactor": "online", "currency": "GBP", "linkedIban": "GB7460161363089842", "linkedAccountNo": "000000003", - "panMasked": "400005******7277", - "panToken": "tok_e1705671689aa4f0e43ff0ec", + "panMasked": "400005******1654", + "panToken": "tok_5e072ba925d6c16efeb06cc9", "expiry": "07/30", "nameOnCard": "ZARDASHT WAISI AMIN", "status": "ACTIVE", @@ -692,7 +1532,21 @@ "onlineOnly": true, "atmEnabled": true, "contactless": true, - "createdAt": "2026-07-19T17:55:35.143Z", + "createdAt": "2026-07-19T18:57:13.025Z", + "exchange": { + "id": "zbank", + "name": "Z Online Bank", + "officeId": 29, + "theme": "zbank", + "bicHint": "ZBKNOMNLXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, "networks": { "visa": { "status": "ISSUED_ONLINE", @@ -721,6 +1575,10 @@ "ecommerce": { "status": "ACTIVE", "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Z Online Bank co-brand · BIC ZBKNOMNLXXX" } } } @@ -733,5 +1591,811 @@ "westernUnion": "RAIL_MAPPED", "atmWorldwide": "ENABLED_LEDGER" }, - "disclaimer": "Virtual Visa debit cards use OMNL BIN 400005 and are spendable on OMNL ledger rails. Live Visa/Mastercard scheme, Apple Pay, Google Pay, UnionPay, ATM consortia, and Western Union agent cash-out require licensed BIN sponsorship and network certification." + "disclaimer": "Virtual Visa debit cards use OMNL BIN 400005 and are spendable on OMNL ledger rails. Live Visa/Mastercard scheme, Apple Pay, Google Pay, UnionPay, ATM consortia, and Western Union agent cash-out require licensed BIN sponsorship and network certification.", + "exchangeCards": [ + { + "id": "zcard_mrs5ow4u_5453", + "brand": "Visa", + "product": "Hermes Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******0195", + "panToken": "tok_aaf6e259f3d41d5c56e7f8ad", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.583Z", + "exchange": { + "id": "hermes", + "name": "Hermes Desk", + "officeId": 32, + "theme": "hermes", + "bicHint": "HRMSOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Hermes Desk co-brand · BIC HRMSOMNL33" + } + } + }, + { + "id": "zcard_mrs5ow4w_6705", + "brand": "Visa", + "product": "OneX Exchange Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******4973", + "panToken": "tok_9f951ee8d4353d83ee5c4262", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.584Z", + "exchange": { + "id": "onex", + "name": "OneX Exchange", + "officeId": null, + "theme": "onex", + "bicHint": "ONEXSHIV33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OneX Exchange co-brand · BIC ONEXSHIV33" + } + } + }, + { + "id": "zcard_mrs5ow4x_7971", + "brand": "Visa", + "product": "NovaBank Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******6675", + "panToken": "tok_4ef70b9a948fcdded219705e", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.585Z", + "exchange": { + "id": "novabank", + "name": "NovaBank", + "officeId": 30, + "theme": "nova", + "bicHint": "NOVAGB2LXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "NovaBank co-brand · BIC NOVAGB2LXXX" + } + } + }, + { + "id": "zcard_mrs5ow4x_4643", + "brand": "Visa", + "product": "HYBX Rail Visa", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******8729", + "panToken": "tok_fc8224d3f7502a5e41f2e342", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:55:45.585Z", + "exchange": { + "id": "hybx", + "name": "HYBX Rail", + "officeId": 3, + "theme": "hybx", + "bicHint": "HYBXOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "HYBX Rail co-brand · BIC HYBXOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrll_6810", + "brand": "Visa", + "product": "Hermes Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******6559", + "panToken": "tok_e825bbd2fa62d18ca75e96ab", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.017Z", + "exchange": { + "id": "hermes", + "name": "Hermes Desk", + "officeId": 32, + "theme": "hermes", + "bicHint": "HRMSOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Hermes Desk co-brand · BIC HRMSOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrln_3155", + "brand": "Visa", + "product": "HYBX Rail Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******8414", + "panToken": "tok_223f296051b822d9d45e74b8", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.019Z", + "exchange": { + "id": "hybx", + "name": "HYBX Rail", + "officeId": 3, + "theme": "hybx", + "bicHint": "HYBXOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "HYBX Rail co-brand · BIC HYBXOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrlp_3960", + "brand": "Visa", + "product": "NovaBank Visa", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******9479", + "panToken": "tok_e775024eda93d6e188ccda43", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.021Z", + "exchange": { + "id": "novabank", + "name": "NovaBank", + "officeId": 30, + "theme": "nova", + "bicHint": "NOVAGB2LXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "NovaBank co-brand · BIC NOVAGB2LXXX" + } + } + }, + { + "id": "zcard_mrs5qrlq_5628", + "brand": "Visa", + "product": "Tsunami Exchange Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******3846", + "panToken": "tok_28b4829974424c190de7298f", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.022Z", + "exchange": { + "id": "tsunami", + "name": "Tsunami Exchange", + "officeId": 15, + "theme": "tsunami", + "bicHint": "TSUNOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Tsunami Exchange co-brand · BIC TSUNOMNL33" + } + } + }, + { + "id": "zcard_mrs5qrlr_3829", + "brand": "Visa", + "product": "OMNL Forex Desk Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******7929", + "panToken": "tok_58d88d0e32020d1252b790be", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.023Z", + "exchange": { + "id": "omnl-fx", + "name": "OMNL Forex Desk", + "officeId": 1, + "theme": "omnl", + "bicHint": "OMNLUS33XXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OMNL Forex Desk co-brand · BIC OMNLUS33XXX" + } + } + }, + { + "id": "zcard_mrs5qrls_9837", + "brand": "Visa", + "product": "OneX Exchange Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******4715", + "panToken": "tok_008ad31961a302c3a2620e85", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.024Z", + "exchange": { + "id": "onex", + "name": "OneX Exchange", + "officeId": null, + "theme": "onex", + "bicHint": "ONEXSHIV33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "OneX Exchange co-brand · BIC ONEXSHIV33" + } + } + }, + { + "id": "zcard_mrs5qrlt_7265", + "brand": "Visa", + "product": "Z Online Bank Visa", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******1654", + "panToken": "tok_5e072ba925d6c16efeb06cc9", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.025Z", + "exchange": { + "id": "zbank", + "name": "Z Online Bank", + "officeId": 29, + "theme": "zbank", + "bicHint": "ZBKNOMNLXXX" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "Z Online Bank co-brand · BIC ZBKNOMNLXXX" + } + } + }, + { + "id": "zcard_mrs5qrlu_9021", + "brand": "Visa", + "product": "AnakaBank Visa", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******5583", + "panToken": "tok_584d20e3cd5a9a3bf30bbb47", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T18:57:13.026Z", + "exchange": { + "id": "anakabank", + "name": "AnakaBank", + "officeId": 31, + "theme": "anaka", + "bicHint": "ANAKOMNL33" + }, + "back": { + "magstripe": true, + "signatureStrip": true, + "cvvPanel": true, + "cvvMasked": "•••", + "printedName": "ZARDASHT WAISI AMIN" + }, + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + }, + "exchange": { + "status": "BRANDED", + "note": "AnakaBank co-brand · BIC ANAKOMNL33" + } + } + } + ] } \ No newline at end of file diff --git a/config/rails-revolut-wise-binance.env.example b/config/rails-revolut-wise-binance.env.example new file mode 100644 index 0000000..a1227a8 --- /dev/null +++ b/config/rails-revolut-wise-binance.env.example @@ -0,0 +1,31 @@ +# Revolut Business + Wise Platform + Binance Spot (production) +# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored). +# Do not commit real tokens. + +# --- Revolut Business API --- +# https://developer.revolut.com/docs/business/business-api +REVOLUT_ENABLED=1 +REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0 +# Sandbox: https://sandbox-b2b.revolut.com/api/1.0 +REVOLUT_ACCESS_TOKEN= +REVOLUT_ACCOUNT_ID= +SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0 + +# --- Wise / TransferWise --- +# https://docs.wise.com/api-docs +WISE_ENABLED=1 +WISE_API_BASE_URL=https://api.transferwise.com +# Sandbox: https://api.sandbox.transferwise.tech +WISE_API_TOKEN= +WISE_PROFILE_ID= +SETTLEMENT_ALLOW_WISE_PRODUCTION=0 + +# --- Binance Spot --- +# https://binance-docs.github.io/apidocs/spot/en/ +BINANCE_ENABLED=1 +BINANCE_API_BASE_URL=https://api.binance.com +BINANCE_API_KEY= +BINANCE_API_SECRET= +SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0 +# Required for live coin withdrawals: +BINANCE_ALLOW_WITHDRAW=0 diff --git a/config/secrets-templates/rails-revolut-wise-binance.env.example b/config/secrets-templates/rails-revolut-wise-binance.env.example new file mode 100644 index 0000000..a1227a8 --- /dev/null +++ b/config/secrets-templates/rails-revolut-wise-binance.env.example @@ -0,0 +1,31 @@ +# Revolut Business + Wise Platform + Binance Spot (production) +# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored). +# Do not commit real tokens. + +# --- Revolut Business API --- +# https://developer.revolut.com/docs/business/business-api +REVOLUT_ENABLED=1 +REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0 +# Sandbox: https://sandbox-b2b.revolut.com/api/1.0 +REVOLUT_ACCESS_TOKEN= +REVOLUT_ACCOUNT_ID= +SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0 + +# --- Wise / TransferWise --- +# https://docs.wise.com/api-docs +WISE_ENABLED=1 +WISE_API_BASE_URL=https://api.transferwise.com +# Sandbox: https://api.sandbox.transferwise.tech +WISE_API_TOKEN= +WISE_PROFILE_ID= +SETTLEMENT_ALLOW_WISE_PRODUCTION=0 + +# --- Binance Spot --- +# https://binance-docs.github.io/apidocs/spot/en/ +BINANCE_ENABLED=1 +BINANCE_API_BASE_URL=https://api.binance.com +BINANCE_API_KEY= +BINANCE_API_SECRET= +SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0 +# Required for live coin withdrawals: +BINANCE_ALLOW_WITHDRAW=0 diff --git a/packages/settlement-core/dist/types.d.ts b/packages/settlement-core/dist/types.d.ts index f9559db..9ff57dd 100644 --- a/packages/settlement-core/dist/types.d.ts +++ b/packages/settlement-core/dist/types.d.ts @@ -1,6 +1,6 @@ /** OMNL central-bank settlement domain types */ export type MoneyLayer = 'M0' | 'M1' | 'M2' | 'M3' | 'M4' | 'M00'; -export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138'; +export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138' | 'REVOLUT' | 'WISE' | 'BINANCE'; export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS'; export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910'; export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'SETTLED' | 'FAILED'; @@ -57,9 +57,10 @@ export type TransferRequest = { recipientAddress: string; senderAddress?: string; moneyLayers: MoneyLayer[]; - rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS'; - /** External SWIFT/HYBX beneficiary IBAN when rail is external */ + rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE'; + /** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */ creditorIban?: string; + creditorBic?: string; beneficiaryName?: string; remittanceInfo?: string; currency?: string; diff --git a/packages/settlement-core/src/types.ts b/packages/settlement-core/src/types.ts index 9a74818..6bd7d9b 100644 --- a/packages/settlement-core/src/types.ts +++ b/packages/settlement-core/src/types.ts @@ -10,7 +10,10 @@ export type PaymentRail = | 'ACH' | 'FEDWIRE' | 'INTERNAL' - | 'CHAIN138'; + | 'CHAIN138' + | 'REVOLUT' + | 'WISE' + | 'BINANCE'; export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS'; @@ -82,9 +85,10 @@ export type TransferRequest = { recipientAddress: string; senderAddress?: string; moneyLayers: MoneyLayer[]; - rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS'; - /** External SWIFT/HYBX beneficiary IBAN when rail is external */ + rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE'; + /** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */ creditorIban?: string; + creditorBic?: string; beneficiaryName?: string; remittanceInfo?: string; currency?: string; diff --git a/scripts/rails/smoke-revolut-wise-binance.mjs b/scripts/rails/smoke-revolut-wise-binance.mjs new file mode 100644 index 0000000..31fec07 --- /dev/null +++ b/scripts/rails/smoke-revolut-wise-binance.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Smoke-test Revolut Business + Wise + Binance credentials. + * Loads secrets/{revolut,wise,binance}/credentials.env then process.env. + * Usage: node scripts/rails/smoke-revolut-wise-binance.mjs + */ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '../..'); + +function loadEnvFile(rel) { + const p = path.join(root, rel); + if (!fs.existsSync(p)) return {}; + const out = {}; + for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) { + if (!line || line.trim().startsWith('#')) continue; + const i = line.indexOf('='); + if (i < 0) continue; + out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, ''); + } + return out; +} + +function applyEnv(obj) { + for (const [k, v] of Object.entries(obj)) { + if (v && (!(k in process.env) || !process.env[k])) process.env[k] = v; + } +} + +function redact(s) { + if (!s) return '(empty)'; + if (s.length <= 10) return '***'; + return `${s.slice(0, 4)}…${s.slice(-4)} (len=${s.length})`; +} + +applyEnv(loadEnvFile('.env')); +applyEnv(loadEnvFile('secrets/revolut/credentials.env')); +applyEnv(loadEnvFile('secrets/wise/credentials.env')); +applyEnv(loadEnvFile('secrets/binance/credentials.env')); + +async function smokeRevolut() { + const token = process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN; + const base = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, ''); + if (!token) return { ok: false, configured: false, error: 'REVOLUT_ACCESS_TOKEN missing' }; + const res = await fetch(`${base}/accounts`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { raw: text.slice(0, 160) }; + } + const accounts = Array.isArray(body) ? body : body?.accounts ?? []; + return { + ok: res.status >= 200 && res.status < 300, + configured: true, + http: res.status, + token: redact(token), + accountCount: Array.isArray(accounts) ? accounts.length : null, + accountIdSet: Boolean(process.env.REVOLUT_ACCOUNT_ID), + liveGate: process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1', + error: res.ok ? undefined : body?.message || text.slice(0, 180), + }; +} + +async function smokeWise() { + const token = process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN; + const base = (process.env.WISE_API_BASE_URL || 'https://api.transferwise.com').replace(/\/$/, ''); + if (!token) return { ok: false, configured: false, error: 'WISE_API_TOKEN missing' }; + const res = await fetch(`${base}/v1/profiles`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { raw: text.slice(0, 160) }; + } + const profiles = Array.isArray(body) ? body : []; + return { + ok: res.status >= 200 && res.status < 300, + configured: true, + http: res.status, + token: redact(token), + profileCount: profiles.length, + profileIdSet: Boolean(process.env.WISE_PROFILE_ID), + liveGate: process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1', + error: res.ok ? undefined : body?.error || body?.message || text.slice(0, 180), + }; +} + +async function smokeBinance() { + const key = process.env.BINANCE_API_KEY; + const secret = process.env.BINANCE_API_SECRET; + const base = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, ''); + if (!key || !secret) return { ok: false, configured: false, error: 'BINANCE_API_KEY/SECRET missing' }; + + const ping = await fetch(`${base}/api/v3/ping`); + const timestamp = Date.now(); + const qs = `timestamp=${timestamp}`; + const sig = crypto.createHmac('sha256', secret).update(qs).digest('hex'); + const res = await fetch(`${base}/api/v3/account?${qs}&signature=${sig}`, { + headers: { 'X-MBX-APIKEY': key, Accept: 'application/json' }, + }); + const text = await res.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { raw: text.slice(0, 160) }; + } + const funded = (body.balances || []).filter((b) => Number(b.free) > 0).length; + return { + ok: ping.ok && res.status >= 200 && res.status < 300, + configured: true, + http: res.status, + ping: ping.status, + apiKey: redact(key), + fundedAssets: funded, + canTrade: body.canTrade, + liveGate: process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1', + withdrawGate: process.env.BINANCE_ALLOW_WITHDRAW === '1', + error: res.ok ? undefined : body?.msg || text.slice(0, 180), + }; +} + +const report = { + asOf: new Date().toISOString(), + revolut: await smokeRevolut(), + wise: await smokeWise(), + binance: await smokeBinance(), +}; + +console.log(JSON.stringify(report, null, 2)); +const anyConfigured = report.revolut.configured || report.wise.configured || report.binance.configured; +const allOk = + (!report.revolut.configured || report.revolut.ok) && + (!report.wise.configured || report.wise.ok) && + (!report.binance.configured || report.binance.ok); +process.exit(anyConfigured && allOk ? 0 : anyConfigured ? 2 : 3); diff --git a/services/settlement-middleware/dist/adapters/binance-exchange.js b/services/settlement-middleware/dist/adapters/binance-exchange.js new file mode 100644 index 0000000..74e9cf6 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/binance-exchange.js @@ -0,0 +1,114 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BinanceExchangeRail = void 0; +exports.loadBinanceConfig = loadBinanceConfig; +/** + * Binance Spot API (production) — account / withdraw / ticker. + * Docs: https://binance-docs.github.io/apidocs/spot/en/ + * Auth: API key + HMAC SHA256 secret. + */ +const crypto_1 = __importDefault(require("crypto")); +const axios_1 = __importDefault(require("axios")); +function loadBinanceConfig() { + const apiKey = (process.env.BINANCE_API_KEY || '').trim(); + const apiSecret = (process.env.BINANCE_API_SECRET || '').trim(); + const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, ''); + return { + baseUrl, + apiKey, + apiSecret, + enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0', + }; +} +class BinanceExchangeRail { + config; + http; + constructor(cfg = loadBinanceConfig()) { + this.config = cfg; + this.http = axios_1.default.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + 'X-MBX-APIKEY': cfg.apiKey, + Accept: 'application/json', + }, + }); + } + isReady() { + return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret); + } + sign(query) { + return crypto_1.default.createHmac('sha256', this.config.apiSecret).update(query).digest('hex'); + } + async signedGet(path, params = {}) { + const timestamp = Date.now(); + const qs = new URLSearchParams({ + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + timestamp: String(timestamp), + }).toString(); + const signature = this.sign(qs); + const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`); + return data; + } + async signedPost(path, params = {}) { + const timestamp = Date.now(); + const body = new URLSearchParams({ + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + timestamp: String(timestamp), + }); + const signature = this.sign(body.toString()); + body.append('signature', signature); + const { data } = await this.http.post(path, body.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return data; + } + async ping() { + const { status } = await this.http.get('/api/v3/ping'); + return status === 200; + } + async accountInfo() { + if (!this.isReady()) + throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)'); + const data = await this.signedGet('/api/v3/account'); + const balances = (data.balances || []) + .filter((b) => Number(b.free) > 0) + .slice(0, 20) + .map((b) => ({ asset: b.asset, free: b.free })); + return { balances, canTrade: Boolean(data.canTrade) }; + } + async tickerPrice(symbol) { + const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } }); + return { symbol: data.symbol, price: data.price }; + } + /** + * Withdraw to external address (requires withdrawal permission on API key). + * Gate with BINANCE_ALLOW_WITHDRAW=1 in production. + */ + async withdraw(payload) { + if (!this.isReady()) + throw new Error('Binance not configured'); + if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') { + throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals'); + } + const params = { + coin: payload.coin.toUpperCase(), + amount: payload.amount, + address: payload.address, + }; + if (payload.network) + params.network = payload.network; + if (payload.name) + params.name = payload.name.slice(0, 30); + const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params); + return { + id: String(data?.id ?? data?.withdrawOrderId ?? ''), + status: 'SUBMITTED', + raw: data, + }; + } +} +exports.BinanceExchangeRail = BinanceExchangeRail; diff --git a/services/settlement-middleware/dist/adapters/external-banks-hub.js b/services/settlement-middleware/dist/adapters/external-banks-hub.js new file mode 100644 index 0000000..a4d1b78 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/external-banks-hub.js @@ -0,0 +1,137 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getExternalRailsStatus = getExternalRailsStatus; +exports.probeExternalRails = probeExternalRails; +exports.dispatchExternalBankOrExchange = dispatchExternalBankOrExchange; +/** + * External banks & exchange hub — Revolut Business + Wise + Binance. + * Fail-closed until credentials present; live dispatch behind env gates. + */ +const revolut_business_1 = require("./revolut-business"); +const wise_platform_1 = require("./wise-platform"); +const binance_exchange_1 = require("./binance-exchange"); +function getExternalRailsStatus() { + const revolut = (0, revolut_business_1.loadRevolutConfig)(); + const wise = (0, wise_platform_1.loadWiseConfig)(); + const binance = (0, binance_exchange_1.loadBinanceConfig)(); + return [ + { + id: 'REVOLUT', + name: 'Revolut Business', + configured: Boolean(revolut.accessToken), + liveDispatchAllowed: Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1', + baseUrl: revolut.baseUrl, + note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID', + }, + { + id: 'WISE', + name: 'Wise Platform', + configured: Boolean(wise.apiToken), + liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1', + baseUrl: wise.baseUrl, + note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)', + }, + { + id: 'BINANCE', + name: 'Binance Exchange', + configured: Boolean(binance.apiKey && binance.apiSecret), + liveDispatchAllowed: Boolean(binance.apiKey && binance.apiSecret) && + process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1', + baseUrl: binance.baseUrl, + note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1', + }, + ]; +} +async function probeExternalRails() { + const status = getExternalRailsStatus(); + const out = []; + for (const s of status) { + if (!s.configured) { + out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } }); + continue; + } + try { + if (s.id === 'REVOLUT') { + const rail = new revolut_business_1.RevolutBusinessRail(); + const profile = (await rail.getProfile()); + out.push({ + ...s, + probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` }, + }); + } + else if (s.id === 'WISE') { + const rail = new wise_platform_1.WisePlatformRail(); + const profiles = await rail.listProfiles(); + out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } }); + } + else { + const rail = new binance_exchange_1.BinanceExchangeRail(); + const ping = await rail.ping(); + const acct = await rail.accountInfo(); + out.push({ + ...s, + probe: { + ok: ping, + detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`, + }, + }); + } + } + catch (e) { + out.push({ + ...s, + probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) }, + }); + } + } + return out; +} +async function dispatchExternalBankOrExchange(payload) { + const allow = (payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') || + (payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') || + (payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1'); + if (!allow) { + throw new Error(`${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`); + } + if (payload.rail === 'REVOLUT') { + const rail = new revolut_business_1.RevolutBusinessRail(); + const r = await rail.createPayment({ + idempotencyKey: payload.idempotencyKey, + amount: payload.amount, + currency: payload.currency, + beneficiaryName: payload.beneficiaryName, + iban: payload.creditorIban, + bic: payload.creditorBic, + reference: payload.remittanceInfo, + }); + return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' }; + } + if (payload.rail === 'WISE') { + if (!payload.creditorIban) + throw new Error('creditorIban required for Wise'); + const rail = new wise_platform_1.WisePlatformRail(); + const r = await rail.payout({ + idempotencyKey: payload.idempotencyKey, + amount: payload.amount, + currency: payload.currency, + beneficiaryName: payload.beneficiaryName, + iban: payload.creditorIban, + bic: payload.creditorBic, + reference: payload.remittanceInfo, + }); + return { providerId: r.transferId, status: r.status, rail: 'WISE' }; + } + // BINANCE + if (!payload.cryptoAddress) + throw new Error('cryptoAddress required for Binance withdraw'); + const rail = new binance_exchange_1.BinanceExchangeRail(); + const coin = (payload.coin || payload.currency || 'USDT').toUpperCase(); + const r = await rail.withdraw({ + coin, + amount: payload.amount, + address: payload.cryptoAddress, + network: payload.cryptoNetwork, + name: payload.beneficiaryName, + }); + return { providerId: r.id, status: r.status, rail: 'BINANCE' }; +} diff --git a/services/settlement-middleware/dist/adapters/revolut-business.js b/services/settlement-middleware/dist/adapters/revolut-business.js new file mode 100644 index 0000000..1976702 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/revolut-business.js @@ -0,0 +1,111 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RevolutBusinessRail = void 0; +exports.loadRevolutConfig = loadRevolutConfig; +/** + * Revolut Business API (production) — accounts / payments. + * Docs: https://developer.revolut.com/docs/business/business-api + * Auth: Bearer access token (OAuth / Business API token). + */ +const axios_1 = __importDefault(require("axios")); +function loadRevolutConfig() { + const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim(); + const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, ''); + return { + baseUrl, + accessToken, + accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined, + enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0', + }; +} +class RevolutBusinessRail { + config; + http; + constructor(cfg = loadRevolutConfig()) { + this.config = cfg; + this.http = axios_1.default.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + Authorization: `Bearer ${cfg.accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } + isReady() { + return this.config.enabled && Boolean(this.config.accessToken); + } + async listAccounts() { + const { data } = await this.http.get('/accounts'); + return Array.isArray(data) ? data : data?.accounts ?? []; + } + async getProfile() { + // Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking + const accounts = await this.listAccounts(); + return { accountCount: accounts.length, sample: accounts.slice(0, 2) }; + } + /** Create a payment draft / counterparty payment (production path). */ + async createPayment(payload) { + if (!this.isReady()) + throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)'); + const accountId = payload.accountId || this.config.accountId; + if (!accountId) + throw new Error('REVOLUT_ACCOUNT_ID required for payments'); + if (!payload.iban) + throw new Error('creditor IBAN required for Revolut payout'); + const body = { + request_id: payload.idempotencyKey.slice(0, 40), + account_id: accountId, + receiver: { + counterparty_id: undefined, + // Direct IBAN payout (Business API pay shape varies by account region) + iban: payload.iban, + bic: payload.bic, + name: payload.beneficiaryName, + }, + amount: Number(payload.amount), + currency: payload.currency.toUpperCase(), + reference: payload.reference || payload.idempotencyKey.slice(0, 35), + }; + const { data, status } = await this.http.post('/pay', body, { + headers: { 'X-Idempotency-Key': payload.idempotencyKey }, + validateStatus: () => true, + }); + if (status >= 400) { + // Fallback: create counterparty then pay (sandbox/prod variance) + const cp = await this.http.post('/counterparty', { + company_name: payload.beneficiaryName, + bank_country: payload.iban.slice(0, 2), + currency: payload.currency.toUpperCase(), + iban: payload.iban, + bic: payload.bic, + }, { validateStatus: () => true }); + if (cp.status >= 400) { + throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`); + } + const pay2 = await this.http.post('/pay', { + request_id: `${payload.idempotencyKey.slice(0, 30)}-2`, + account_id: accountId, + receiver: { counterparty_id: cp.data?.id }, + amount: Number(payload.amount), + currency: payload.currency.toUpperCase(), + reference: payload.reference || 'OMNL', + }, { headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } }); + return { + paymentId: String(pay2.data?.id ?? payload.idempotencyKey), + status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'), + raw: pay2.data, + }; + } + return { + paymentId: String(data?.id ?? payload.idempotencyKey), + status: String(data?.state ?? data?.status ?? 'PENDING'), + raw: data, + }; + } +} +exports.RevolutBusinessRail = RevolutBusinessRail; diff --git a/services/settlement-middleware/dist/adapters/wise-platform.js b/services/settlement-middleware/dist/adapters/wise-platform.js new file mode 100644 index 0000000..2b24b36 --- /dev/null +++ b/services/settlement-middleware/dist/adapters/wise-platform.js @@ -0,0 +1,142 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WisePlatformRail = void 0; +exports.loadWiseConfig = loadWiseConfig; +/** + * Wise Platform API (production) — profiles / quotes / transfers. + * Docs: https://docs.wise.com/api-docs + * Auth: Bearer personal/business API token. + */ +const axios_1 = __importDefault(require("axios")); +function loadWiseConfig() { + const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim(); + const baseUrl = (process.env.WISE_API_BASE_URL || + process.env.TRANSFERWISE_API_BASE_URL || + 'https://api.transferwise.com').replace(/\/$/, ''); + return { + baseUrl, + apiToken, + profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined, + enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0', + }; +} +class WisePlatformRail { + config; + http; + constructor(cfg = loadWiseConfig()) { + this.config = cfg; + this.http = axios_1.default.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + Authorization: `Bearer ${cfg.apiToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } + isReady() { + return this.config.enabled && Boolean(this.config.apiToken); + } + async listProfiles() { + const { data } = await this.http.get('/v1/profiles'); + return Array.isArray(data) ? data : []; + } + async resolveProfileId() { + if (this.config.profileId) + return this.config.profileId; + const profiles = await this.listProfiles(); + const first = profiles[0]; + if (!first?.id) + throw new Error('Wise profile not found — set WISE_PROFILE_ID'); + return String(first.id); + } + async createQuote(payload) { + if (!this.isReady()) + throw new Error('Wise not configured (WISE_API_TOKEN)'); + const profileId = await this.resolveProfileId(); + const body = { + profile: Number(profileId) || profileId, + sourceCurrency: payload.sourceCurrency.toUpperCase(), + targetCurrency: payload.targetCurrency.toUpperCase(), + payOut: 'BANK_TRANSFER', + }; + if (payload.sourceAmount) + body.sourceAmount = Number(payload.sourceAmount); + else if (payload.targetAmount) + body.targetAmount = Number(payload.targetAmount); + else + throw new Error('sourceAmount or targetAmount required'); + const { data } = await this.http.post('/v3/quotes', body); + return { + quoteId: String(data?.id ?? data?.uuid ?? ''), + rate: data?.rate != null ? Number(data.rate) : undefined, + raw: data, + }; + } + async createRecipient(payload) { + const profileId = await this.resolveProfileId(); + const currency = payload.currency.toUpperCase(); + const type = payload.type || (payload.iban ? 'iban' : 'email'); + const details = {}; + if (payload.iban) { + details.IBAN = payload.iban; + if (payload.bic) + details.BIC = payload.bic; + } + const { data } = await this.http.post('/v1/accounts', { + profile: Number(profileId) || profileId, + accountHolderName: payload.accountHolderName, + currency, + type, + details, + }); + return { accountId: Number(data?.id), raw: data }; + } + async createTransfer(payload) { + if (!this.isReady()) + throw new Error('Wise not configured (WISE_API_TOKEN)'); + const { data } = await this.http.post('/v1/transfers', { + targetAccount: payload.targetAccountId, + quoteUuid: payload.quoteId, + customerTransactionId: payload.idempotencyKey.slice(0, 36), + details: { reference: payload.reference || 'OMNL Z Online Bank' }, + }); + return { + transferId: String(data?.id ?? payload.idempotencyKey), + status: String(data?.status ?? 'incoming_payment_waiting'), + raw: data, + }; + } + /** Full payout: quote → recipient → transfer (fund separately if required). */ + async payout(payload) { + const source = payload.currency.toUpperCase(); + const target = (payload.targetCurrency || payload.currency).toUpperCase(); + const quote = await this.createQuote({ + sourceCurrency: source, + targetCurrency: target, + sourceAmount: payload.amount, + }); + const recipient = await this.createRecipient({ + currency: target, + accountHolderName: payload.beneficiaryName, + iban: payload.iban, + bic: payload.bic, + }); + const transfer = await this.createTransfer({ + idempotencyKey: payload.idempotencyKey, + quoteId: quote.quoteId, + targetAccountId: recipient.accountId, + reference: payload.reference, + }); + return { + transferId: transfer.transferId, + status: transfer.status, + quoteId: quote.quoteId, + }; + } +} +exports.WisePlatformRail = WisePlatformRail; diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 1deb915..caab099 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -15,6 +15,7 @@ const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity"); const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement"); const fineract_1 = require("../../adapters/fineract"); const pricing_1 = require("../../adapters/pricing"); +const external_banks_hub_1 = require("../../adapters/external-banks-hub"); const auth_1 = require("../../middleware/auth"); async function respondPublicMoneySupply(res, officeId) { const cfg = (0, config_1.loadConfig)(); @@ -353,16 +354,54 @@ function createSettlementRouter() { }); return; } - if (!body.creditorIban) { - res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' }); + if (!body.creditorIban && body.rail !== 'BINANCE') { + res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' }); return; } + const externalRail = body.rail === 'RTGS' + ? 'RTGS' + : body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE' + ? body.rail + : 'SWIFT'; const record = await (0, transfer_1.processTransfer)({ ...body, officeId: (0, config_1.settlementOfficeId)(body.officeId), - rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT', + rail: externalRail, moneyLayers: body.moneyLayers ?? ['M2', 'M4'], }); + if (record.phase !== 'FAILED' && + (body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE')) { + try { + const dispatched = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({ + rail: body.rail, + idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId, + amount: String(body.amount), + currency: String(body.currency || 'USD'), + beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary', + creditorIban: body.creditorIban, + creditorBic: body.creditorBic, + remittanceInfo: body.remittanceInfo, + cryptoAddress: body.recipientAddress, + cryptoNetwork: body.cryptoNetwork, + coin: body.tokenSymbol || body.currency, + }); + res.status(200).json({ + ...record, + externalRail: dispatched, + transferableExternal: true, + mode: body.rail === 'BINANCE' ? 'exchange' : 'bank', + }); + return; + } + catch (e) { + res.status(422).json({ + ...record, + phase: 'FAILED', + errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)], + }); + return; + } + } res.status(record.phase === 'FAILED' ? 422 : 200).json({ ...record, transferableExternal: true, @@ -373,6 +412,56 @@ function createSettlementRouter() { res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); } }); + router.get('/rails/external', (_req, res) => { + res.json({ + asOf: new Date().toISOString(), + rails: (0, external_banks_hub_1.getExternalRailsStatus)(), + usage: { + transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE', + probe: 'GET /rails/external/probe', + smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs', + }, + }); + }); + router.get('/rails/external/probe', async (req, res) => { + if (!(0, auth_1.requireApiKey)(req, res)) + return; + try { + const rails = await (0, external_banks_hub_1.probeExternalRails)(); + res.json({ asOf: new Date().toISOString(), rails }); + } + catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + router.post('/rails/external/dispatch', async (req, res) => { + if (!(0, auth_1.requireApiKey)(req, res)) + return; + try { + const rail = String(req.body?.rail || '').toUpperCase(); + if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) { + res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' }); + return; + } + const out = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({ + rail, + idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`), + amount: String(req.body?.amount || ''), + currency: String(req.body?.currency || 'USD'), + beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'), + creditorIban: req.body?.creditorIban, + creditorBic: req.body?.creditorBic, + remittanceInfo: req.body?.remittanceInfo, + cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress, + cryptoNetwork: req.body?.cryptoNetwork, + coin: req.body?.coin || req.body?.tokenSymbol, + }); + res.status(200).json({ ok: true, ...out }); + } + catch (e) { + res.status(422).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); router.get('/tokens/m2', (_req, res) => { try { const fs = require('fs'); diff --git a/services/settlement-middleware/dist/index.js b/services/settlement-middleware/dist/index.js index 4954e3e..4349562 100644 --- a/services/settlement-middleware/dist/index.js +++ b/services/settlement-middleware/dist/index.js @@ -44,4 +44,16 @@ const rootEnv = path_1.default.resolve(__dirname, '../../../.env'); if ((0, fs_1.existsSync)(rootEnv)) dotenv.config({ path: rootEnv }); dotenv.config(); +/** Production rail secrets (gitignored) — Revolut / Wise / Binance */ +const repoRoot = path_1.default.resolve(__dirname, '../../..'); +for (const rel of [ + 'secrets/revolut/credentials.env', + 'secrets/wise/credentials.env', + 'secrets/binance/credentials.env', + 'config/rails-revolut-wise-binance.env', +]) { + const p = path_1.default.join(repoRoot, rel); + if ((0, fs_1.existsSync)(p)) + dotenv.config({ path: p, override: false }); +} (0, server_1.startServer)(); diff --git a/services/settlement-middleware/dist/workflows/transfer.js b/services/settlement-middleware/dist/workflows/transfer.js index 541ddfa..3cd5c68 100644 --- a/services/settlement-middleware/dist/workflows/transfer.js +++ b/services/settlement-middleware/dist/workflows/transfer.js @@ -12,7 +12,8 @@ const settlement_store_1 = require("../store/settlement-store"); const btc_l1_settlement_1 = require("./btc-l1-settlement"); /** * INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled. - * SWIFT / RTGS → bank rail (IBAN). + * SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN). + * BINANCE → exchange withdraw (address) after ledger journal. * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain). */ async function processTransfer(input) { @@ -20,13 +21,15 @@ async function processTransfer(input) { const profile = (0, config_1.loadOfficeProfile)(); const officeId = (0, config_1.settlementOfficeId)(input.officeId); const req = { ...input, officeId }; - const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS'; + const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS' || req.rail === 'REVOLUT' || req.rail === 'WISE'; + const isExchangeExternal = req.rail === 'BINANCE'; /** Crypto leaving OMNL custody to any 0x wallet (external transferable). */ const isCryptoExternal = !isBankExternal && + !isExchangeExternal && Boolean(req.tokenAddress?.startsWith('0x')) && Boolean(req.recipientAddress?.startsWith('0x')) && (req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO')); - const isExternal = isBankExternal || isCryptoExternal; + const isExternal = isBankExternal || isCryptoExternal || isExchangeExternal; const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey); if (existing?.phase === 'SETTLED') return existing; @@ -42,20 +45,34 @@ async function processTransfer(input) { currency: req.currency ?? (req.tokenSymbol === 'cBTC' ? 'BTC' : 'USD'), amount: req.amount, creditorIban: req.creditorIban ?? - (isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'), + (isBankExternal + ? '' + : isExchangeExternal + ? `EXT-BINANCE-${req.recipientAddress || 'withdraw'}` + : isCryptoExternal + ? `EXT-CRYPTO-${req.recipientAddress}` + : 'INTERNAL-CHAIN138'), beneficiaryName: req.beneficiaryName ?? req.recipientAddress, remittanceInfo: req.remittanceInfo ?? (isCryptoExternal ? `EXTERNAL_CRYPTO ${req.tokenSymbol} → ${req.recipientAddress}` - : `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`), + : isExchangeExternal + ? `BINANCE_WITHDRAW ${req.currency || req.tokenSymbol}` + : `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`), moneyLayers: req.moneyLayers.length ? req.moneyLayers - : isBankExternal + : isBankExternal || isExchangeExternal ? ['M2', 'M4'] : isCryptoExternal ? ['M3', 'M4'] : ['M2', 'M3'], - rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT', + rail: req.rail === 'CHAIN138' + ? 'CHAIN138' + : req.rail === 'INTERNAL' + ? 'INTERNAL' + : req.rail === 'REVOLUT' || req.rail === 'WISE' || req.rail === 'BINANCE' + ? req.rail + : 'SWIFT', }, errors: [], createdAt: now, @@ -69,7 +86,11 @@ async function processTransfer(input) { if (isBankExternal && !req.creditorIban) { throw new Error('creditorIban required for external bank transfer'); } - if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) { + if (isExchangeExternal && !req.recipientAddress?.startsWith('0x') && !req.recipientAddress) { + throw new Error('recipientAddress required for Binance withdraw'); + } + if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && + !req.recipientAddress?.startsWith('0x')) { throw new Error('recipientAddress (0x) required for on-chain transfer'); } advance('VALIDATED'); diff --git a/services/settlement-middleware/src/adapters/binance-exchange.ts b/services/settlement-middleware/src/adapters/binance-exchange.ts new file mode 100644 index 0000000..0925203 --- /dev/null +++ b/services/settlement-middleware/src/adapters/binance-exchange.ts @@ -0,0 +1,126 @@ +/** + * Binance Spot API (production) — account / withdraw / ticker. + * Docs: https://binance-docs.github.io/apidocs/spot/en/ + * Auth: API key + HMAC SHA256 secret. + */ +import crypto from 'crypto'; +import axios, { AxiosInstance } from 'axios'; + +export type BinanceConfig = { + baseUrl: string; + apiKey: string; + apiSecret: string; + enabled: boolean; +}; + +export function loadBinanceConfig(): BinanceConfig { + const apiKey = (process.env.BINANCE_API_KEY || '').trim(); + const apiSecret = (process.env.BINANCE_API_SECRET || '').trim(); + const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, ''); + return { + baseUrl, + apiKey, + apiSecret, + enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0', + }; +} + +export class BinanceExchangeRail { + readonly config: BinanceConfig; + private readonly http: AxiosInstance; + + constructor(cfg = loadBinanceConfig()) { + this.config = cfg; + this.http = axios.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + 'X-MBX-APIKEY': cfg.apiKey, + Accept: 'application/json', + }, + }); + } + + isReady(): boolean { + return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret); + } + + private sign(query: string): string { + return crypto.createHmac('sha256', this.config.apiSecret).update(query).digest('hex'); + } + + private async signedGet(path: string, params: Record = {}) { + const timestamp = Date.now(); + const qs = new URLSearchParams({ + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + timestamp: String(timestamp), + }).toString(); + const signature = this.sign(qs); + const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`); + return data; + } + + private async signedPost(path: string, params: Record = {}) { + const timestamp = Date.now(); + const body = new URLSearchParams({ + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + timestamp: String(timestamp), + }); + const signature = this.sign(body.toString()); + body.append('signature', signature); + const { data } = await this.http.post(path, body.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + return data; + } + + async ping(): Promise { + const { status } = await this.http.get('/api/v3/ping'); + return status === 200; + } + + async accountInfo(): Promise<{ balances: Array<{ asset: string; free: string }>; canTrade: boolean }> { + if (!this.isReady()) throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)'); + const data = await this.signedGet('/api/v3/account'); + const balances = (data.balances || []) + .filter((b: { free: string }) => Number(b.free) > 0) + .slice(0, 20) + .map((b: { asset: string; free: string }) => ({ asset: b.asset, free: b.free })); + return { balances, canTrade: Boolean(data.canTrade) }; + } + + async tickerPrice(symbol: string): Promise<{ symbol: string; price: string }> { + const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } }); + return { symbol: data.symbol, price: data.price }; + } + + /** + * Withdraw to external address (requires withdrawal permission on API key). + * Gate with BINANCE_ALLOW_WITHDRAW=1 in production. + */ + async withdraw(payload: { + coin: string; + amount: string; + address: string; + network?: string; + name?: string; + }): Promise<{ id: string; status: string; raw: unknown }> { + if (!this.isReady()) throw new Error('Binance not configured'); + if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') { + throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals'); + } + const params: Record = { + coin: payload.coin.toUpperCase(), + amount: payload.amount, + address: payload.address, + }; + if (payload.network) params.network = payload.network; + if (payload.name) params.name = payload.name.slice(0, 30); + const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params); + return { + id: String(data?.id ?? data?.withdrawOrderId ?? ''), + status: 'SUBMITTED', + raw: data, + }; + } +} diff --git a/services/settlement-middleware/src/adapters/external-banks-hub.ts b/services/settlement-middleware/src/adapters/external-banks-hub.ts new file mode 100644 index 0000000..ae1267d --- /dev/null +++ b/services/settlement-middleware/src/adapters/external-banks-hub.ts @@ -0,0 +1,165 @@ +/** + * External banks & exchange hub — Revolut Business + Wise + Binance. + * Fail-closed until credentials present; live dispatch behind env gates. + */ +import { RevolutBusinessRail, loadRevolutConfig } from './revolut-business'; +import { WisePlatformRail, loadWiseConfig } from './wise-platform'; +import { BinanceExchangeRail, loadBinanceConfig } from './binance-exchange'; + +export type ExternalRailId = 'REVOLUT' | 'WISE' | 'BINANCE'; + +export type RailStatus = { + id: ExternalRailId; + name: string; + configured: boolean; + liveDispatchAllowed: boolean; + baseUrl: string; + note: string; +}; + +export function getExternalRailsStatus(): RailStatus[] { + const revolut = loadRevolutConfig(); + const wise = loadWiseConfig(); + const binance = loadBinanceConfig(); + return [ + { + id: 'REVOLUT', + name: 'Revolut Business', + configured: Boolean(revolut.accessToken), + liveDispatchAllowed: + Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1', + baseUrl: revolut.baseUrl, + note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID', + }, + { + id: 'WISE', + name: 'Wise Platform', + configured: Boolean(wise.apiToken), + liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1', + baseUrl: wise.baseUrl, + note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)', + }, + { + id: 'BINANCE', + name: 'Binance Exchange', + configured: Boolean(binance.apiKey && binance.apiSecret), + liveDispatchAllowed: + Boolean(binance.apiKey && binance.apiSecret) && + process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1', + baseUrl: binance.baseUrl, + note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1', + }, + ]; +} + +export async function probeExternalRails(): Promise< + Array +> { + const status = getExternalRailsStatus(); + const out: Array = []; + + for (const s of status) { + if (!s.configured) { + out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } }); + continue; + } + try { + if (s.id === 'REVOLUT') { + const rail = new RevolutBusinessRail(); + const profile = (await rail.getProfile()) as { accountCount?: number }; + out.push({ + ...s, + probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` }, + }); + } else if (s.id === 'WISE') { + const rail = new WisePlatformRail(); + const profiles = await rail.listProfiles(); + out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } }); + } else { + const rail = new BinanceExchangeRail(); + const ping = await rail.ping(); + const acct = await rail.accountInfo(); + out.push({ + ...s, + probe: { + ok: ping, + detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`, + }, + }); + } + } catch (e) { + out.push({ + ...s, + probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) }, + }); + } + } + return out; +} + +export async function dispatchExternalBankOrExchange(payload: { + rail: ExternalRailId; + idempotencyKey: string; + amount: string; + currency: string; + beneficiaryName: string; + creditorIban?: string; + creditorBic?: string; + remittanceInfo?: string; + /** Binance withdraw */ + cryptoAddress?: string; + cryptoNetwork?: string; + coin?: string; +}): Promise<{ providerId: string; status: string; rail: ExternalRailId }> { + const allow = + (payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') || + (payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') || + (payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1'); + if (!allow) { + throw new Error( + `${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`, + ); + } + + if (payload.rail === 'REVOLUT') { + const rail = new RevolutBusinessRail(); + const r = await rail.createPayment({ + idempotencyKey: payload.idempotencyKey, + amount: payload.amount, + currency: payload.currency, + beneficiaryName: payload.beneficiaryName, + iban: payload.creditorIban, + bic: payload.creditorBic, + reference: payload.remittanceInfo, + }); + return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' }; + } + + if (payload.rail === 'WISE') { + if (!payload.creditorIban) throw new Error('creditorIban required for Wise'); + const rail = new WisePlatformRail(); + const r = await rail.payout({ + idempotencyKey: payload.idempotencyKey, + amount: payload.amount, + currency: payload.currency, + beneficiaryName: payload.beneficiaryName, + iban: payload.creditorIban, + bic: payload.creditorBic, + reference: payload.remittanceInfo, + }); + return { providerId: r.transferId, status: r.status, rail: 'WISE' }; + } + + // BINANCE + if (!payload.cryptoAddress) throw new Error('cryptoAddress required for Binance withdraw'); + const rail = new BinanceExchangeRail(); + const coin = (payload.coin || payload.currency || 'USDT').toUpperCase(); + const r = await rail.withdraw({ + coin, + amount: payload.amount, + address: payload.cryptoAddress, + network: payload.cryptoNetwork, + name: payload.beneficiaryName, + }); + return { providerId: r.id, status: r.status, rail: 'BINANCE' }; +} diff --git a/services/settlement-middleware/src/adapters/revolut-business.ts b/services/settlement-middleware/src/adapters/revolut-business.ts new file mode 100644 index 0000000..a6e144d --- /dev/null +++ b/services/settlement-middleware/src/adapters/revolut-business.ts @@ -0,0 +1,133 @@ +/** + * Revolut Business API (production) — accounts / payments. + * Docs: https://developer.revolut.com/docs/business/business-api + * Auth: Bearer access token (OAuth / Business API token). + */ +import axios, { AxiosInstance } from 'axios'; + +export type RevolutConfig = { + baseUrl: string; + accessToken: string; + accountId?: string; + enabled: boolean; +}; + +export function loadRevolutConfig(): RevolutConfig { + const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim(); + const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, ''); + return { + baseUrl, + accessToken, + accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined, + enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0', + }; +} + +export class RevolutBusinessRail { + readonly config: RevolutConfig; + private readonly http: AxiosInstance; + + constructor(cfg = loadRevolutConfig()) { + this.config = cfg; + this.http = axios.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + Authorization: `Bearer ${cfg.accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } + + isReady(): boolean { + return this.config.enabled && Boolean(this.config.accessToken); + } + + async listAccounts(): Promise { + const { data } = await this.http.get('/accounts'); + return Array.isArray(data) ? data : data?.accounts ?? []; + } + + async getProfile(): Promise { + // Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking + const accounts = await this.listAccounts(); + return { accountCount: accounts.length, sample: accounts.slice(0, 2) }; + } + + /** Create a payment draft / counterparty payment (production path). */ + async createPayment(payload: { + idempotencyKey: string; + amount: string; + currency: string; + beneficiaryName: string; + iban?: string; + bic?: string; + accountId?: string; + reference?: string; + }): Promise<{ paymentId: string; status: string; raw: unknown }> { + if (!this.isReady()) throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)'); + const accountId = payload.accountId || this.config.accountId; + if (!accountId) throw new Error('REVOLUT_ACCOUNT_ID required for payments'); + if (!payload.iban) throw new Error('creditor IBAN required for Revolut payout'); + + const body = { + request_id: payload.idempotencyKey.slice(0, 40), + account_id: accountId, + receiver: { + counterparty_id: undefined as string | undefined, + // Direct IBAN payout (Business API pay shape varies by account region) + iban: payload.iban, + bic: payload.bic, + name: payload.beneficiaryName, + }, + amount: Number(payload.amount), + currency: payload.currency.toUpperCase(), + reference: payload.reference || payload.idempotencyKey.slice(0, 35), + }; + + const { data, status } = await this.http.post('/pay', body, { + headers: { 'X-Idempotency-Key': payload.idempotencyKey }, + validateStatus: () => true, + }); + if (status >= 400) { + // Fallback: create counterparty then pay (sandbox/prod variance) + const cp = await this.http.post( + '/counterparty', + { + company_name: payload.beneficiaryName, + bank_country: payload.iban.slice(0, 2), + currency: payload.currency.toUpperCase(), + iban: payload.iban, + bic: payload.bic, + }, + { validateStatus: () => true }, + ); + if (cp.status >= 400) { + throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`); + } + const pay2 = await this.http.post( + '/pay', + { + request_id: `${payload.idempotencyKey.slice(0, 30)}-2`, + account_id: accountId, + receiver: { counterparty_id: cp.data?.id }, + amount: Number(payload.amount), + currency: payload.currency.toUpperCase(), + reference: payload.reference || 'OMNL', + }, + { headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } }, + ); + return { + paymentId: String(pay2.data?.id ?? payload.idempotencyKey), + status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'), + raw: pay2.data, + }; + } + return { + paymentId: String(data?.id ?? payload.idempotencyKey), + status: String(data?.state ?? data?.status ?? 'PENDING'), + raw: data, + }; + } +} diff --git a/services/settlement-middleware/src/adapters/wise-platform.ts b/services/settlement-middleware/src/adapters/wise-platform.ts new file mode 100644 index 0000000..0e2f261 --- /dev/null +++ b/services/settlement-middleware/src/adapters/wise-platform.ts @@ -0,0 +1,171 @@ +/** + * Wise Platform API (production) — profiles / quotes / transfers. + * Docs: https://docs.wise.com/api-docs + * Auth: Bearer personal/business API token. + */ +import axios, { AxiosInstance } from 'axios'; + +export type WiseConfig = { + baseUrl: string; + apiToken: string; + profileId?: string; + enabled: boolean; +}; + +export function loadWiseConfig(): WiseConfig { + const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim(); + const baseUrl = ( + process.env.WISE_API_BASE_URL || + process.env.TRANSFERWISE_API_BASE_URL || + 'https://api.transferwise.com' + ).replace(/\/$/, ''); + return { + baseUrl, + apiToken, + profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined, + enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0', + }; +} + +export class WisePlatformRail { + readonly config: WiseConfig; + private readonly http: AxiosInstance; + + constructor(cfg = loadWiseConfig()) { + this.config = cfg; + this.http = axios.create({ + baseURL: cfg.baseUrl, + timeout: 60000, + headers: { + Authorization: `Bearer ${cfg.apiToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } + + isReady(): boolean { + return this.config.enabled && Boolean(this.config.apiToken); + } + + async listProfiles(): Promise { + const { data } = await this.http.get('/v1/profiles'); + return Array.isArray(data) ? data : []; + } + + async resolveProfileId(): Promise { + if (this.config.profileId) return this.config.profileId; + const profiles = await this.listProfiles(); + const first = profiles[0] as { id?: number | string } | undefined; + if (!first?.id) throw new Error('Wise profile not found — set WISE_PROFILE_ID'); + return String(first.id); + } + + async createQuote(payload: { + sourceCurrency: string; + targetCurrency: string; + sourceAmount?: string; + targetAmount?: string; + }): Promise<{ quoteId: string; rate?: number; raw: unknown }> { + if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)'); + const profileId = await this.resolveProfileId(); + const body: Record = { + profile: Number(profileId) || profileId, + sourceCurrency: payload.sourceCurrency.toUpperCase(), + targetCurrency: payload.targetCurrency.toUpperCase(), + payOut: 'BANK_TRANSFER', + }; + if (payload.sourceAmount) body.sourceAmount = Number(payload.sourceAmount); + else if (payload.targetAmount) body.targetAmount = Number(payload.targetAmount); + else throw new Error('sourceAmount or targetAmount required'); + + const { data } = await this.http.post('/v3/quotes', body); + return { + quoteId: String(data?.id ?? data?.uuid ?? ''), + rate: data?.rate != null ? Number(data.rate) : undefined, + raw: data, + }; + } + + async createRecipient(payload: { + currency: string; + accountHolderName: string; + iban?: string; + bic?: string; + type?: string; + }): Promise<{ accountId: number; raw: unknown }> { + const profileId = await this.resolveProfileId(); + const currency = payload.currency.toUpperCase(); + const type = payload.type || (payload.iban ? 'iban' : 'email'); + const details: Record = {}; + if (payload.iban) { + details.IBAN = payload.iban; + if (payload.bic) details.BIC = payload.bic; + } + const { data } = await this.http.post('/v1/accounts', { + profile: Number(profileId) || profileId, + accountHolderName: payload.accountHolderName, + currency, + type, + details, + }); + return { accountId: Number(data?.id), raw: data }; + } + + async createTransfer(payload: { + idempotencyKey: string; + quoteId: string; + targetAccountId: number; + reference?: string; + }): Promise<{ transferId: string; status: string; raw: unknown }> { + if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)'); + const { data } = await this.http.post('/v1/transfers', { + targetAccount: payload.targetAccountId, + quoteUuid: payload.quoteId, + customerTransactionId: payload.idempotencyKey.slice(0, 36), + details: { reference: payload.reference || 'OMNL Z Online Bank' }, + }); + return { + transferId: String(data?.id ?? payload.idempotencyKey), + status: String(data?.status ?? 'incoming_payment_waiting'), + raw: data, + }; + } + + /** Full payout: quote → recipient → transfer (fund separately if required). */ + async payout(payload: { + idempotencyKey: string; + amount: string; + currency: string; + targetCurrency?: string; + beneficiaryName: string; + iban: string; + bic?: string; + reference?: string; + }): Promise<{ transferId: string; status: string; quoteId: string }> { + const source = payload.currency.toUpperCase(); + const target = (payload.targetCurrency || payload.currency).toUpperCase(); + const quote = await this.createQuote({ + sourceCurrency: source, + targetCurrency: target, + sourceAmount: payload.amount, + }); + const recipient = await this.createRecipient({ + currency: target, + accountHolderName: payload.beneficiaryName, + iban: payload.iban, + bic: payload.bic, + }); + const transfer = await this.createTransfer({ + idempotencyKey: payload.idempotencyKey, + quoteId: quote.quoteId, + targetAccountId: recipient.accountId, + reference: payload.reference, + }); + return { + transferId: transfer.transferId, + status: transfer.status, + quoteId: quote.quoteId, + }; + } +} diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index 33a0c27..83cdaf9 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -29,6 +29,12 @@ import { } from '../../workflows/btc-l1-settlement'; import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract'; import { resolveLiveBtcUsd } from '../../adapters/pricing'; +import { + dispatchExternalBankOrExchange, + getExternalRailsStatus, + probeExternalRails, + type ExternalRailId, +} from '../../adapters/external-banks-hub'; import { requireApiKey } from '../../middleware/auth'; async function respondPublicMoneySupply(res: Response, officeId: number): Promise { @@ -368,16 +374,58 @@ export function createSettlementRouter(): Router { return; } - if (!body.creditorIban) { - res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' }); + if (!body.creditorIban && body.rail !== 'BINANCE') { + res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' }); return; } + const externalRail = + body.rail === 'RTGS' + ? 'RTGS' + : body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE' + ? body.rail + : 'SWIFT'; const record = await processTransfer({ ...body, officeId: settlementOfficeId(body.officeId), - rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT', + rail: externalRail, moneyLayers: body.moneyLayers ?? ['M2', 'M4'], }); + + if ( + record.phase !== 'FAILED' && + (body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE') + ) { + try { + const dispatched = await dispatchExternalBankOrExchange({ + rail: body.rail as ExternalRailId, + idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId, + amount: String(body.amount), + currency: String(body.currency || 'USD'), + beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary', + creditorIban: body.creditorIban, + creditorBic: body.creditorBic, + remittanceInfo: body.remittanceInfo, + cryptoAddress: body.recipientAddress, + cryptoNetwork: (body as { cryptoNetwork?: string }).cryptoNetwork, + coin: body.tokenSymbol || body.currency, + }); + res.status(200).json({ + ...record, + externalRail: dispatched, + transferableExternal: true, + mode: body.rail === 'BINANCE' ? 'exchange' : 'bank', + }); + return; + } catch (e) { + res.status(422).json({ + ...record, + phase: 'FAILED', + errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)], + }); + return; + } + } + res.status(record.phase === 'FAILED' ? 422 : 200).json({ ...record, transferableExternal: true, @@ -388,6 +436,55 @@ export function createSettlementRouter(): Router { } }); + router.get('/rails/external', (_req, res) => { + res.json({ + asOf: new Date().toISOString(), + rails: getExternalRailsStatus(), + usage: { + transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE', + probe: 'GET /rails/external/probe', + smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs', + }, + }); + }); + + router.get('/rails/external/probe', async (req, res) => { + if (!requireApiKey(req, res)) return; + try { + const rails = await probeExternalRails(); + res.json({ asOf: new Date().toISOString(), rails }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + + router.post('/rails/external/dispatch', async (req, res) => { + if (!requireApiKey(req, res)) return; + try { + const rail = String(req.body?.rail || '').toUpperCase() as ExternalRailId; + if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) { + res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' }); + return; + } + const out = await dispatchExternalBankOrExchange({ + rail, + idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`), + amount: String(req.body?.amount || ''), + currency: String(req.body?.currency || 'USD'), + beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'), + creditorIban: req.body?.creditorIban, + creditorBic: req.body?.creditorBic, + remittanceInfo: req.body?.remittanceInfo, + cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress, + cryptoNetwork: req.body?.cryptoNetwork, + coin: req.body?.coin || req.body?.tokenSymbol, + }); + res.status(200).json({ ok: true, ...out }); + } catch (e) { + res.status(422).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + router.get('/tokens/m2', (_req, res) => { try { const fs = require('fs') as typeof import('fs'); diff --git a/services/settlement-middleware/src/index.ts b/services/settlement-middleware/src/index.ts index 919126c..8ab82ef 100644 --- a/services/settlement-middleware/src/index.ts +++ b/services/settlement-middleware/src/index.ts @@ -7,4 +7,16 @@ const rootEnv = path.resolve(__dirname, '../../../.env'); if (existsSync(rootEnv)) dotenv.config({ path: rootEnv }); dotenv.config(); +/** Production rail secrets (gitignored) — Revolut / Wise / Binance */ +const repoRoot = path.resolve(__dirname, '../../..'); +for (const rel of [ + 'secrets/revolut/credentials.env', + 'secrets/wise/credentials.env', + 'secrets/binance/credentials.env', + 'config/rails-revolut-wise-binance.env', +]) { + const p = path.join(repoRoot, rel); + if (existsSync(p)) dotenv.config({ path: p, override: false }); +} + startServer(); diff --git a/services/settlement-middleware/src/workflows/transfer.ts b/services/settlement-middleware/src/workflows/transfer.ts index 873994d..829d6c2 100644 --- a/services/settlement-middleware/src/workflows/transfer.ts +++ b/services/settlement-middleware/src/workflows/transfer.ts @@ -10,7 +10,8 @@ import { allowChainMintExecute } from './btc-l1-settlement'; /** * INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled. - * SWIFT / RTGS → bank rail (IBAN). + * SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN). + * BINANCE → exchange withdraw (address) after ledger journal. * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain). */ export async function processTransfer(input: TransferRequest): Promise { @@ -18,14 +19,17 @@ export async function processTransfer(input: TransferRequest): Promise + + +OMNL — Revolut · Wise · Binance rails + +
+
+ REVOLUT BUSINESS + WISE PLATFORM + BINANCE SPOT +
+

External production rails

+

Live adapters for Revolut Business, Wise, and Binance. Credentials load from env / secrets/*. Live payouts require explicit SETTLEMENT_ALLOW_*_PRODUCTION=1 gates.

+ + + +
RailConfiguredLive gateBase URLNote
Loading…
+

+ Settlement: GET /api/v1/settlement/rails/external
+ Transfer: POST /transfer/external with rail=REVOLUT|WISE|BINANCE
+ Smoke: node scripts/rails/smoke-revolut-wise-binance.mjs
+ Cards: /zbank/cards · Random pack includes Revolut / Wise / Binance themes +

+
+ + diff --git a/services/token-aggregation/public/zbank-online-cards.html b/services/token-aggregation/public/zbank-online-cards.html index 4270bae..5439e8d 100644 --- a/services/token-aggregation/public/zbank-online-cards.html +++ b/services/token-aggregation/public/zbank-online-cards.html @@ -65,6 +65,22 @@ } .face.front.eur { background: linear-gradient(135deg, #1b4332 0%, #2d6a4f 50%, #081c15 100%); } .face.front.gbp { background: linear-gradient(135deg, #4a1942 0%, #7b2d5e 45%, #1a0a16 100%); } + .face.front.theme-nova { background: linear-gradient(135deg, #0ea5e9 0%, #0369a1 50%, #0c4a6e 100%); } + .face.front.theme-anaka { background: linear-gradient(135deg, #a855f7 0%, #6b21a8 50%, #3b0764 100%); } + .face.front.theme-onex { background: linear-gradient(135deg, #f97316 0%, #c2410c 50%, #7c2d12 100%); } + .face.front.theme-omnl { background: linear-gradient(135deg, #d4af37 0%, #8a6d1a 55%, #3d3008 100%); } + .face.front.theme-hermes { background: linear-gradient(135deg, #14b8a6 0%, #0f766e 50%, #134e4a 100%); } + .face.front.theme-tsunami { background: linear-gradient(135deg, #38bdf8 0%, #0284c7 45%, #082f49 100%); } + .face.front.theme-hybx { background: linear-gradient(135deg, #ef4444 0%, #991b1b 50%, #450a0a 100%); } + .face.front.theme-zbank { background: linear-gradient(135deg, #1a1f71 0%, #0b3d91 45%, #162447 100%); } + .face.front.theme-revolut { background: linear-gradient(135deg, #191c1f 0%, #2d3436 45%, #636e72 100%); } + .face.front.theme-wise { background: linear-gradient(135deg, #9fe870 0%, #2e7d32 50%, #1b5e20 100%); color:#0a0a0a; } + .face.front.theme-binance { background: linear-gradient(135deg, #f0b90b 0%, #c99400 45%, #3d2e00 100%); } + .ex-name { + position: absolute; top: 1rem; left: 1.15rem; + font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; + opacity: .95; max-width: 55%; + } .face.back { transform: rotateY(180deg); background: linear-gradient(160deg, #1c2430 0%, #121820 55%, #0a0e14 100%); @@ -202,10 +218,10 @@
FRONT + BACK ONLINE VISA - CVV / MAGSTRIPE + RANDOM EXCHANGES

Online Cards — Front & Back

-

Hover or click a card to flip. Back shows magnetic stripe, signature strip, and CVV. Create front + back for each IBAN wallet.

+

Hover or click to flip. Create Z Online cards or mint a random pack of exchange-branded Visas (Nova, Anaka, OneX, HYBX, Tsunami…).

@@ -222,14 +238,18 @@ +
+ + +

Issued cards

- - + +
CurrencyMasked PANCVVExpiryBackIBAN
Loading…
ExchangeCurrencyMasked PANCVVExpiryBackIBAN
Loading…

/zbank/cards · @@ -243,6 +263,8 @@ const tbody = document.getElementById('tbody'); const result = document.getElementById('result'); const issueBtn = document.getElementById('issue'); const issueAllBtn = document.getElementById('issueAll'); +const issueRandomBtn = document.getElementById('issueRandom'); +const issueRandom8Btn = document.getElementById('issueRandom8'); const sessionSecrets = {}; // panMasked -> { pan, cvv } function cvvDisplay(c) { @@ -259,11 +281,14 @@ function cvvDisplay(c) { function cardPairHtml(c) { const cls = (c.currency || '').toLowerCase(); + const theme = c.exchange?.theme ? `theme-${c.exchange.theme}` : (cls === 'usd' ? 'theme-zbank' : cls); const cvv = cvvDisplay(c); const last4 = (c.panMasked || '').slice(-4); + const exLabel = c.exchange?.name || 'Z Online Bank'; return `

-
+
+
${exLabel}
${c.currency} FRONT
${c.panMasked || '•••• •••• •••• ••••'}
@@ -284,13 +309,13 @@ function cardPairHtml(c) {
CVV${cvv}
- ${c.currency} BACK · Z Online Bank
+ ${exLabel} · ${c.currency} BACK
Debit · **** ${last4} · Exp ${c.expiry || '—'}
- IBAN ${(c.linkedIban || '').slice(0, 8)}… · ATM / e‑comm enabled + BIC ${c.exchange?.bicHint || 'ZBKNOMNLXXX'} · IBAN ${(c.linkedIban || '').slice(0, 8)}…
-
hover / click → back
+
hover / click → flip
`; } @@ -302,10 +327,11 @@ function collectCards(data) { if (a.card) cards.push({ ...a.card, currency: a.currency, linkedIban: a.iban }); for (const oc of a.onlineCards || []) cards.push(oc); } + for (const ec of data.exchangeCards || []) cards.push(ec); const seen = new Set(); const uniq = []; for (const c of cards) { - const k = `${c.currency}:${c.panMasked}`; + const k = `${c.currency}:${c.panMasked}:${c.exchange?.id || ''}`; if (seen.has(k)) continue; seen.add(k); uniq.push(c); @@ -320,7 +346,7 @@ async function load() { return; } const uniq = collectCards(await r.json()); - faces.innerHTML = uniq.slice(0, 9).map(cardPairHtml).join('') || '

No cards yet.

'; + faces.innerHTML = uniq.slice(0, 12).map(cardPairHtml).join('') || '

No cards yet.

'; faces.querySelectorAll('.flip-wrap').forEach((el) => { el.addEventListener('click', () => el.classList.toggle('flipped')); el.addEventListener('keydown', (e) => { @@ -328,13 +354,14 @@ async function load() { }); }); tbody.innerHTML = uniq.map(c => ` + ${c.exchange?.name || 'Z Online'} ${c.currency || ''} ${c.panMasked || ''} ${cvvDisplay(c)} ${c.expiry || ''} READY ${c.linkedIban || ''} - `).join('') || 'None yet'; + `).join('') || 'None yet'; } async function issueOne(currency, revealPan) { @@ -384,7 +411,7 @@ issueAllBtn.addEventListener('click', async () => { lines.push(`${currency} ${c.panMasked} CVV ${c.cvv || c.cvvMasked || '•••'} BACK READY`); } result.classList.add('show'); - result.innerHTML = `CREATED 3× FRONT + BACK\\n` + lines.join('\\n'); + result.innerHTML = `CREATED 3× FRONT + BACK
` + lines.join('
'); await load(); } catch (e) { result.classList.add('show'); @@ -394,6 +421,55 @@ issueAllBtn.addEventListener('click', async () => { } }); +async function issueRandomPack(count) { + const revealPan = document.getElementById('reveal').checked; + const r = await fetch('/zbank/api/cards/random-exchanges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count, revealPan }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || r.statusText); + for (const c of j.cards || []) { + if (c.panMasked && (c.pan || c.cvv)) sessionSecrets[c.panMasked] = { pan: c.pan, cvv: c.cvv }; + } + return j.cards || []; +} + +issueRandomBtn.addEventListener('click', async () => { + issueRandomBtn.disabled = true; + result.classList.remove('show'); + try { + const cards = await issueRandomPack(4); + result.classList.add('show'); + result.innerHTML = `RANDOM EXCHANGE PACK ×${cards.length}
` + + cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked}`).join('
'); + await load(); + } catch (e) { + result.classList.add('show'); + result.textContent = 'Error: ' + (e && e.message ? e.message : e); + } finally { + issueRandomBtn.disabled = false; + } +}); + +issueRandom8Btn.addEventListener('click', async () => { + issueRandom8Btn.disabled = true; + result.classList.remove('show'); + try { + const cards = await issueRandomPack(8); + result.classList.add('show'); + result.innerHTML = `FULL EXCHANGE PACK ×${cards.length}
` + + cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked} · ${c.exchange?.bicHint || ''}`).join('
'); + await load(); + } catch (e) { + result.classList.add('show'); + result.textContent = 'Error: ' + (e && e.message ? e.message : e); + } finally { + issueRandom8Btn.disabled = false; + } +}); + load(); diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index f5890a3..b5abf97 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -36,7 +36,7 @@ import metamaskPriceRoutes from './routes/metamask-prices'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; import { getDatabasePool } from '../database/client'; -import { issueCardForCurrency, loadCustomerRegistry } from '../lib/zbank-online-cards'; +import { issueCardForCurrency, issueRandomExchangeCards, loadCustomerRegistry, EXCHANGE_CATALOG } from '../lib/zbank-online-cards'; import winston from 'winston'; // Setup logger @@ -299,6 +299,22 @@ export class ApiServer { this.app.get('/zbank/cards', sendZbankOnlineCards); this.app.get('/zbank/online-cards', sendZbankOnlineCards); + const railsHubPath = path.join(__dirname, '../../public/rails-revolut-wise-binance.html'); + this.app.get('/rails/revolut-wise-binance', (_req: Request, res: Response) => { + if (!existsSync(railsHubPath)) { + res.status(404).type('text/plain').send('rails-revolut-wise-binance.html missing'); + return; + } + res.type('html').send(readFileSync(railsHubPath, 'utf8')); + }); + this.app.get('/zbank/rails', (_req: Request, res: Response) => { + if (!existsSync(railsHubPath)) { + res.status(404).type('text/plain').send('rails page missing'); + return; + } + res.type('html').send(readFileSync(railsHubPath, 'utf8')); + }); + const repoRoot = path.resolve(__dirname, '../../../..'); this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => { try { @@ -317,7 +333,8 @@ export class ApiServer { try { const currency = String(req.body?.currency || 'USD').toUpperCase(); const revealPan = Boolean(req.body?.revealPan); - const out = issueCardForCurrency(repoRoot, currency, revealPan); + const exchange = req.body?.exchange ? String(req.body.exchange) : undefined; + const out = issueCardForCurrency(repoRoot, currency, revealPan, exchange); if (!out.ok) { res.status(400).json({ error: out.error }); return; @@ -328,6 +345,25 @@ export class ApiServer { } }); + this.app.get('/zbank/api/exchanges', (_req: Request, res: Response) => { + res.json({ exchanges: EXCHANGE_CATALOG }); + }); + + this.app.post('/zbank/api/cards/random-exchanges', (req: Request, res: Response) => { + try { + const count = Math.min(8, Math.max(1, Number(req.body?.count || 4))); + const revealPan = Boolean(req.body?.revealPan); + const out = issueRandomExchangeCards(repoRoot, count, revealPan); + if (!out.ok) { + res.status(400).json({ error: out.error }); + return; + } + res.status(201).json({ ok: true, count: out.cards.length, cards: out.cards }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + // Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts) const sendApiV1Catalog = (_req: Request, res: Response) => { res.json({ diff --git a/services/token-aggregation/src/lib/zbank-online-cards.ts b/services/token-aggregation/src/lib/zbank-online-cards.ts index 0ee3a50..a69ca85 100644 --- a/services/token-aggregation/src/lib/zbank-online-cards.ts +++ b/services/token-aggregation/src/lib/zbank-online-cards.ts @@ -24,6 +24,14 @@ export type OnlineCard = { atmEnabled: boolean; contactless: boolean; createdAt: string; + /** Exchange / ISB member branding on the plastic. */ + exchange?: { + id: string; + name: string; + officeId: number | null; + theme: string; + bicHint: string; + }; /** Back-of-card panel (magstripe / signature / CVV area). */ back: { magstripe: true; @@ -35,6 +43,40 @@ export type OnlineCard = { networks: Record; }; +/** OMNL ISB / partner exchanges for random branded cards. */ +export const EXCHANGE_CATALOG = [ + { id: 'zbank', name: 'Z Online Bank', officeId: 29, theme: 'zbank', bicHint: 'ZBKNOMNLXXX' }, + { id: 'novabank', name: 'NovaBank', officeId: 30, theme: 'nova', bicHint: 'NOVAGB2LXXX' }, + { id: 'anakabank', name: 'AnakaBank', officeId: 31, theme: 'anaka', bicHint: 'ANAKOMNL33' }, + { id: 'onex', name: 'OneX Exchange', officeId: null, theme: 'onex', bicHint: 'ONEXSHIV33' }, + { id: 'omnl-fx', name: 'OMNL Forex Desk', officeId: 1, theme: 'omnl', bicHint: 'OMNLUS33XXX' }, + { id: 'hermes', name: 'Hermes Desk', officeId: 32, theme: 'hermes', bicHint: 'HRMSOMNL33' }, + { id: 'tsunami', name: 'Tsunami Exchange', officeId: 15, theme: 'tsunami', bicHint: 'TSUNOMNL33' }, + { id: 'hybx', name: 'HYBX Rail', officeId: 3, theme: 'hybx', bicHint: 'HYBXOMNL33' }, + { id: 'revolut', name: 'Revolut Business', officeId: null, theme: 'revolut', bicHint: 'REVOGB2LXXX' }, + { id: 'wise', name: 'Wise Bank', officeId: null, theme: 'wise', bicHint: 'TRWIUS33XXX' }, + { id: 'binance', name: 'Binance Exchange', officeId: null, theme: 'binance', bicHint: 'BINANCE' }, +] as const; + +export type ExchangeDef = (typeof EXCHANGE_CATALOG)[number]; + +export function pickRandomExchanges(count: number): ExchangeDef[] { + const pool = [...EXCHANGE_CATALOG]; + const out: ExchangeDef[] = []; + const n = Math.min(Math.max(1, count), pool.length); + for (let i = 0; i < n; i++) { + const idx = randomInt(0, pool.length); + out.push(pool.splice(idx, 1)[0]); + } + return out; +} + +export function pickRandomCurrency(accounts: Array<{ currency?: string }>): string { + const codes = accounts.map((a) => String(a.currency || '').toUpperCase()).filter(Boolean); + if (codes.length === 0) return 'USD'; + return codes[randomInt(0, codes.length)]; +} + function luhnCheckDigit(partial: string): string { let sum = 0; let dbl = true; @@ -83,6 +125,7 @@ export function buildOnlineCard(opts: { iban: string; accountNo: string | null; nameOnCard: string; + exchange?: ExchangeDef; }): OnlineCard & { pan?: string; cvv?: string } { const pan = issueOnlineVisaPan(); const cvv = String(randomInt(100, 999)); @@ -90,10 +133,12 @@ export function buildOnlineCard(opts: { const expYear = String(new Date().getFullYear() + 4).slice(-2); const id = `zcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`; const nameOnCard = opts.nameOnCard.toUpperCase(); + const exchange = opts.exchange; + const product = exchange ? `${exchange.name} Visa` : 'Z Online Card'; return { id, brand: 'Visa', - product: 'Z Online Card', + product, formFactor: 'online', currency: opts.currency, linkedIban: opts.iban, @@ -110,6 +155,17 @@ export function buildOnlineCard(opts: { createdAt: new Date().toISOString(), pan, cvv, + ...(exchange + ? { + exchange: { + id: exchange.id, + name: exchange.name, + officeId: exchange.officeId, + theme: exchange.theme, + bicHint: exchange.bicHint, + }, + } + : {}), back: { magstripe: true, signatureStrip: true, @@ -125,6 +181,10 @@ export function buildOnlineCard(opts: { westernUnion: { status: 'RAIL_MAPPED', note: 'WU via SWIFT/HYBX' }, atm: { status: 'ENABLED_LEDGER', note: 'ATM when BIN switch live' }, ecommerce: { status: 'ACTIVE', note: 'Online CNP spend on linked IBAN balance' }, + exchange: { + status: exchange ? 'BRANDED' : 'ZBANK', + note: exchange ? `${exchange.name} co-brand · BIC ${exchange.bicHint}` : 'Z Online Bank default', + }, }, }; } @@ -133,6 +193,7 @@ export function issueCardForCurrency( repoRoot: string, currency: string, revealPan = false, + exchange?: ExchangeDef | string, ): { ok: true; card: OnlineCard; account: Record } | { ok: false; error: string } { const reg = loadCustomerRegistry(repoRoot); if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' }; @@ -141,19 +202,31 @@ export function issueCardForCurrency( if (!acct) return { ok: false, error: `No IBAN account for ${currency}` }; const customer = reg.customer as { displayName?: string }; + let ex: ExchangeDef | undefined; + if (typeof exchange === 'string' && exchange) { + ex = EXCHANGE_CATALOG.find((e) => e.id === exchange.toLowerCase() || e.theme === exchange.toLowerCase()); + } else if (exchange && typeof exchange === 'object') { + ex = exchange; + } + const card = buildOnlineCard({ currency: String(acct.currency), iban: String(acct.iban), accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null, nameOnCard: customer.displayName || 'CARDHOLDER', + exchange: ex, }); const { pan, cvv, ...publicCard } = card; const onlineCards = (Array.isArray(acct.onlineCards) ? acct.onlineCards : []) as OnlineCard[]; onlineCards.push(publicCard); acct.onlineCards = onlineCards; - // Primary online card pointer acct.onlineCard = publicCard; + + const exchangeCards = (Array.isArray(reg.exchangeCards) ? reg.exchangeCards : []) as OnlineCard[]; + if (ex) exchangeCards.push(publicCard); + reg.exchangeCards = exchangeCards; + saveCustomerRegistry(repoRoot, reg); const out: OnlineCard & { pan?: string; cvv?: string } = { ...publicCard }; @@ -163,3 +236,25 @@ export function issueCardForCurrency( } return { ok: true, card: out, account: acct }; } + +/** Issue N randomly selected exchange-branded cards across funded IBANs. */ +export function issueRandomExchangeCards( + repoRoot: string, + count = 4, + revealPan = false, +): { ok: true; cards: OnlineCard[] } | { ok: false; error: string } { + const reg = loadCustomerRegistry(repoRoot); + if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' }; + const accounts = (reg.accounts as Array>) || []; + if (accounts.length === 0) return { ok: false, error: 'No IBAN accounts' }; + + const exchanges = pickRandomExchanges(count); + const cards: OnlineCard[] = []; + for (const ex of exchanges) { + const currency = pickRandomCurrency(accounts as Array<{ currency?: string }>); + const issued = issueCardForCurrency(repoRoot, currency, revealPan, ex); + if (!issued.ok) return { ok: false, error: issued.error }; + cards.push(issued.card); + } + return { ok: true, cards }; +}