diff --git a/config/customers/zardasht-waisi-amin.zbank.v1.json b/config/customers/zardasht-waisi-amin.zbank.v1.json
new file mode 100644
index 0000000..04465ce
--- /dev/null
+++ b/config/customers/zardasht-waisi-amin.zbank.v1.json
@@ -0,0 +1,737 @@
+{
+ "version": "1.0.0",
+ "asOf": "2026-07-19T17:55:35.143Z",
+ "bank": {
+ "name": "Z Online Bank",
+ "officeId": 29,
+ "externalId": "ZBANK-OMNL",
+ "bic": "ZBKNOMNLXXX",
+ "swiftMemberStub": true
+ },
+ "customer": {
+ "firstname": "Zardasht",
+ "lastname": "Waisi Amin",
+ "displayName": "Zardasht Waisi Amin",
+ "externalId": "ZBANK-CUST-ZARDASHT-WAISI-AMIN",
+ "email": "zardasht.waisi.amin@omdnl.org",
+ "mobile": "+9647700000001",
+ "officeId": 29,
+ "fineractClientId": 19,
+ "fineractAccountNo": "000000019"
+ },
+ "bic": "ZBKNOMNLXXX",
+ "accounts": [
+ {
+ "currency": "USD",
+ "iban": "LU890010000063089842",
+ "bic": "ZBKNOMNLXXX",
+ "swift": "ZBKNOMNLXXX",
+ "balance": 1000000,
+ "fineractAccountNo": "000000001",
+ "fineractSavingsId": 1,
+ "fineractStatus": "Active",
+ "spendable": true,
+ "card": {
+ "brand": "Visa",
+ "product": "OMNL Z Online Debit",
+ "formFactor": "virtual",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******9515",
+ "panToken": "tok_a2708a298be420b28f91dbb8",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "networks": {
+ "visa": {
+ "status": "ISSUED_LEDGER",
+ "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor"
+ },
+ "applePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Apple Pay Payee / issuer TSP"
+ },
+ "googlePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Google Pay OPC / issuer TSP"
+ },
+ "unionPay": {
+ "status": "PROVISION_READY",
+ "note": "Pending UnionPay dual-brand / issuer agreement"
+ },
+ "westernUnion": {
+ "status": "RAIL_MAPPED",
+ "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter"
+ },
+ "atm": {
+ "status": "ENABLED_LEDGER",
+ "note": "ATM cash-out via correspondent/ATM switch when BIN live"
+ }
+ }
+ },
+ "onlineCards": [
+ {
+ "id": "zcard_mrs34bz1_9046",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******6727",
+ "panToken": "tok_a997672e5545f2fe55baaf91",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:43:47.102Z",
+ "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"
+ }
+ }
+ },
+ {
+ "id": "zcard_mrs35wwu_9534",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******0431",
+ "panToken": "tok_09a1e1c66c6d3d514c322c78",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:45:00.894Z",
+ "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"
+ }
+ }
+ },
+ {
+ "id": "zcard_mrs36bc8_7413",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******5692",
+ "panToken": "tok_512a2450c63740fee6956fb8",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:45:19.592Z",
+ "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"
+ }
+ }
+ },
+ {
+ "id": "zcard_mrs3ji8l_6109",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******8371",
+ "panToken": "tok_8f114918cde825a53e76a911",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.063Z",
+ "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"
+ }
+ }
+ }
+ ],
+ "onlineCard": {
+ "id": "zcard_mrs3ji8l_6109",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "USD",
+ "linkedIban": "LU890010000063089842",
+ "linkedAccountNo": "000000001",
+ "panMasked": "400005******8371",
+ "panToken": "tok_8f114918cde825a53e76a911",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.063Z",
+ "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"
+ }
+ }
+ }
+ },
+ {
+ "currency": "EUR",
+ "iban": "DE73500105173775892661",
+ "bic": "ZBKNOMNLXXX",
+ "swift": "ZBKNOMNLXXX",
+ "balance": 1000000,
+ "fineractAccountNo": "000000002",
+ "fineractSavingsId": 2,
+ "fineractStatus": "Active",
+ "spendable": true,
+ "card": {
+ "brand": "Visa",
+ "product": "OMNL Z Online Debit",
+ "formFactor": "virtual",
+ "currency": "EUR",
+ "linkedIban": "DE73500105173775892661",
+ "linkedAccountNo": "000000002",
+ "panMasked": "400005******1039",
+ "panToken": "tok_663ab4f2ea2e88a6b114a0fc",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "networks": {
+ "visa": {
+ "status": "ISSUED_LEDGER",
+ "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor"
+ },
+ "applePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Apple Pay Payee / issuer TSP"
+ },
+ "googlePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Google Pay OPC / issuer TSP"
+ },
+ "unionPay": {
+ "status": "PROVISION_READY",
+ "note": "Pending UnionPay dual-brand / issuer agreement"
+ },
+ "westernUnion": {
+ "status": "RAIL_MAPPED",
+ "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter"
+ },
+ "atm": {
+ "status": "ENABLED_LEDGER",
+ "note": "ATM cash-out via correspondent/ATM switch when BIN live"
+ }
+ }
+ },
+ "onlineCards": [
+ {
+ "id": "zcard_mrs34bz6_1976",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "EUR",
+ "linkedIban": "DE73500105173775892661",
+ "linkedAccountNo": "000000002",
+ "panMasked": "400005******8584",
+ "panToken": "tok_b0c06cdab692c26fe5c87701",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:43:47.106Z",
+ "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"
+ }
+ }
+ },
+ {
+ "id": "zcard_mrs3jiao_7411",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "EUR",
+ "linkedIban": "DE73500105173775892661",
+ "linkedAccountNo": "000000002",
+ "panMasked": "400005******8745",
+ "panToken": "tok_945a6c603e918c6f8c20ff17",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.136Z",
+ "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"
+ }
+ }
+ }
+ ],
+ "onlineCard": {
+ "id": "zcard_mrs3jiao_7411",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "EUR",
+ "linkedIban": "DE73500105173775892661",
+ "linkedAccountNo": "000000002",
+ "panMasked": "400005******8745",
+ "panToken": "tok_945a6c603e918c6f8c20ff17",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.136Z",
+ "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"
+ }
+ }
+ }
+ },
+ {
+ "currency": "GBP",
+ "iban": "GB7460161363089842",
+ "bic": "ZBKNOMNLXXX",
+ "swift": "ZBKNOMNLXXX",
+ "balance": 1000000,
+ "fineractAccountNo": "000000003",
+ "fineractSavingsId": 3,
+ "fineractStatus": "Active",
+ "spendable": true,
+ "card": {
+ "brand": "Visa",
+ "product": "OMNL Z Online Debit",
+ "formFactor": "virtual",
+ "currency": "GBP",
+ "linkedIban": "GB7460161363089842",
+ "linkedAccountNo": "000000003",
+ "panMasked": "400005******6234",
+ "panToken": "tok_fab1d1a25cbc632859aff1f3",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "networks": {
+ "visa": {
+ "status": "ISSUED_LEDGER",
+ "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor"
+ },
+ "applePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Apple Pay Payee / issuer TSP"
+ },
+ "googlePay": {
+ "status": "PROVISION_READY",
+ "note": "Tokenization pending Google Pay OPC / issuer TSP"
+ },
+ "unionPay": {
+ "status": "PROVISION_READY",
+ "note": "Pending UnionPay dual-brand / issuer agreement"
+ },
+ "westernUnion": {
+ "status": "RAIL_MAPPED",
+ "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter"
+ },
+ "atm": {
+ "status": "ENABLED_LEDGER",
+ "note": "ATM cash-out via correspondent/ATM switch when BIN live"
+ }
+ }
+ },
+ "onlineCards": [
+ {
+ "id": "zcard_mrs34bz6_6552",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "GBP",
+ "linkedIban": "GB7460161363089842",
+ "linkedAccountNo": "000000003",
+ "panMasked": "400005******2306",
+ "panToken": "tok_0c315e1b57f1bc3aebaa8573",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:43:47.107Z",
+ "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"
+ }
+ }
+ },
+ {
+ "id": "zcard_mrs3jiav_8336",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "GBP",
+ "linkedIban": "GB7460161363089842",
+ "linkedAccountNo": "000000003",
+ "panMasked": "400005******7277",
+ "panToken": "tok_e1705671689aa4f0e43ff0ec",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.143Z",
+ "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"
+ }
+ }
+ }
+ ],
+ "onlineCard": {
+ "id": "zcard_mrs3jiav_8336",
+ "brand": "Visa",
+ "product": "Z Online Card",
+ "formFactor": "online",
+ "currency": "GBP",
+ "linkedIban": "GB7460161363089842",
+ "linkedAccountNo": "000000003",
+ "panMasked": "400005******7277",
+ "panToken": "tok_e1705671689aa4f0e43ff0ec",
+ "expiry": "07/30",
+ "nameOnCard": "ZARDASHT WAISI AMIN",
+ "status": "ACTIVE",
+ "spendable": true,
+ "onlineOnly": true,
+ "atmEnabled": true,
+ "contactless": true,
+ "createdAt": "2026-07-19T17:55:35.143Z",
+ "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"
+ }
+ }
+ }
+ }
+ ],
+ "walletPay": {
+ "applePay": "PROVISION_READY",
+ "googlePay": "PROVISION_READY",
+ "unionPay": "PROVISION_READY",
+ "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."
+}
\ No newline at end of file
diff --git a/scripts/banking/provision-zardasht-iban-cards.mjs b/scripts/banking/provision-zardasht-iban-cards.mjs
new file mode 100644
index 0000000..4f8a3aa
--- /dev/null
+++ b/scripts/banking/provision-zardasht-iban-cards.mjs
@@ -0,0 +1,618 @@
+#!/usr/bin/env node
+/**
+ * Provision Z Online Bank (zBank / Office 29) multi-currency IBAN accounts
+ * + virtual Visa debit cards for a named customer.
+ *
+ * Ledger/SoR: Apache Fineract (savings accounts + deposits).
+ * Cards: OMNL-issued virtual debit instruments linked to each IBAN.
+ * Scheme networks (Visa DPS / Apple Pay / Google Pay / UnionPay / WU / ATM):
+ * recorded as ready-for-issuer-binding — require BIN sponsorship / BaaS.
+ *
+ * Usage:
+ * node scripts/banking/provision-zardasht-iban-cards.mjs
+ */
+import { createHash, randomBytes, randomInt } from 'node:crypto';
+import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '../..');
+const OUT_DIR = join(ROOT, 'config/customers');
+const OUT_FILE = join(OUT_DIR, 'zardasht-waisi-amin.zbank.v1.json');
+const DASH_FILE = join(ROOT, 'services/token-aggregation/public/zbank-zardasht.html');
+
+const CUSTOMER = {
+ firstname: 'Zardasht',
+ lastname: 'Waisi Amin',
+ displayName: 'Zardasht Waisi Amin',
+ externalId: 'ZBANK-CUST-ZARDASHT-WAISI-AMIN',
+ email: 'zardasht.waisi.amin@omdnl.org',
+ mobile: '+9647700000001',
+ officeId: Number(process.env.ZBANK_OFFICE_ID || 29),
+};
+
+const FUND_AMOUNT = Number(process.env.ZBANK_FUND_AMOUNT || 1_000_000);
+const BIC = process.env.ZBANK_BIC || 'ZBKNOMNLXXX'; // zBank OMNL
+const BANK_CODE_GB = '601613'; // sort-code style BBAN bank id
+const BANK_CODE_DE = '50010517'; // BLZ-style for EUR IBAN
+const BANK_CODE_LU = '001'; // LU bank code for USD multi-ccy book
+
+function loadEnvFile(path) {
+ if (!existsSync(path)) return;
+ for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
+ const m = line.match(/^\s*([^#=]+)=(.*)$/);
+ if (!m) continue;
+ const k = m[1].trim();
+ const v = m[2].trim().replace(/^["']|["']$/g, '');
+ if (!(k in process.env) || process.env[k] === '') process.env[k] = v;
+ }
+}
+loadEnvFile(join(ROOT, '.env'));
+
+function fineractBase() {
+ return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
+}
+function fineractHeaders() {
+ const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl';
+ const user =
+ process.env.OMNL_FINERACT_USER?.trim() ||
+ process.env.OMNL_FINERACT_USERNAME?.trim() ||
+ 'ali_hospitallers_tenant';
+ const pass = process.env.OMNL_FINERACT_PASSWORD || '';
+ if (!fineractBase() || !pass) throw new Error('Fineract credentials required');
+ return {
+ Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`,
+ 'Fineract-Platform-TenantId': tenant,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ };
+}
+
+async function fineract(method, path, body) {
+ const url = `${fineractBase()}${path}`;
+ const res = await fetch(url, {
+ method,
+ headers: fineractHeaders(),
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ let json = null;
+ try {
+ json = text ? JSON.parse(text) : null;
+ } catch {
+ json = { raw: text };
+ }
+ if (!res.ok) {
+ const msg = json?.defaultUserMessage || json?.message || text.slice(0, 400);
+ const err = new Error(`${method} ${path} → ${res.status}: ${msg}`);
+ err.status = res.status;
+ err.body = json;
+ throw err;
+ }
+ return json;
+}
+
+/** ISO 13616 mod-97 IBAN check digits */
+function ibanCheckDigits(country, bban) {
+ const rearranged = `${bban}${country}00`.toUpperCase();
+ const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
+ let rem = 0n;
+ for (const ch of numeric) rem = (rem * 10n + BigInt(ch)) % 97n;
+ const check = Number(98n - rem);
+ return String(check).padStart(2, '0');
+}
+
+function buildIban(country, bban) {
+ const clean = bban.replace(/\s/g, '').toUpperCase();
+ const cd = ibanCheckDigits(country, clean);
+ return `${country}${cd}${clean}`;
+}
+
+function isIbanValid(iban) {
+ const n = iban.replace(/\s/g, '').toUpperCase();
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(n)) return false;
+ const rearranged = n.slice(4) + n.slice(0, 4);
+ const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
+ let rem = 0;
+ for (const ch of numeric) rem = (rem * 10 + parseInt(ch, 10)) % 97;
+ return rem === 1;
+}
+
+function luhnCheckDigit(partial) {
+ let sum = 0;
+ let dbl = true;
+ for (let i = partial.length - 1; i >= 0; i--) {
+ let d = Number(partial[i]);
+ if (dbl) {
+ d *= 2;
+ if (d > 9) d -= 9;
+ }
+ sum += d;
+ dbl = !dbl;
+ }
+ return String((10 - (sum % 10)) % 10);
+}
+
+/** OMNL private BIN 400005 (Visa-format Luhn) — not a scheme-sponsored production BIN */
+function issueVirtualVisaPan() {
+ const bin = '400005';
+ const account = String(randomInt(0, 1e9)).padStart(9, '0');
+ const partial = `${bin}${account}`;
+ return partial + luhnCheckDigit(partial);
+}
+
+function cardToken(pan) {
+ return `tok_${createHash('sha256').update(pan).digest('hex').slice(0, 24)}`;
+}
+
+function todayYmd() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+async function ensureGbpCurrency() {
+ try {
+ const cur = await fineract('GET', '/currencies');
+ const codes = (cur.selectedCurrencyOptions || []).map((c) => c.code);
+ if (codes.includes('GBP')) return true;
+ // Fineract PUT currencies replaces selected set — merge carefully
+ const selected = [...new Set([...codes, 'GBP'])];
+ await fineract('PUT', '/currencies', { currencies: selected });
+ console.log('Added GBP to Fineract selected currencies');
+ return true;
+ } catch (e) {
+ console.warn('GBP currency not available:', e.message);
+ return false;
+ }
+}
+
+async function findClientByExternalId(externalId) {
+ const data = await fineract('GET', `/clients?externalId=${encodeURIComponent(externalId)}&limit=5`);
+ const items = data.pageItems || (Array.isArray(data) ? data : []);
+ return items[0] || null;
+}
+
+async function ensureClient() {
+ let client = await findClientByExternalId(CUSTOMER.externalId);
+ if (client) {
+ console.log(`Client exists id=${client.id} status=${client.status?.value}`);
+ } else {
+ const created = await fineract('POST', '/clients', {
+ officeId: CUSTOMER.officeId,
+ firstname: CUSTOMER.firstname,
+ lastname: CUSTOMER.lastname,
+ externalId: CUSTOMER.externalId,
+ active: true,
+ activationDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ mobileNo: CUSTOMER.mobile,
+ emailAddress: CUSTOMER.email,
+ legalFormId: 1,
+ });
+ client = { id: created.clientId || created.resourceId, ...created };
+ console.log(`Created client id=${client.id}`);
+ }
+ // Activate if pending
+ const detail = await fineract('GET', `/clients/${client.id}`);
+ if (!detail.active) {
+ await fineract('POST', `/clients/${client.id}?command=activate`, {
+ activationDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ console.log(`Activated client ${client.id}`);
+ }
+ return await fineract('GET', `/clients/${client.id}`);
+}
+
+async function findSavingsProduct(shortName) {
+ const products = await fineract('GET', '/savingsproducts');
+ const list = Array.isArray(products) ? products : products.pageItems || [];
+ return list.find((p) => p.shortName === shortName || p.name === shortName) || null;
+}
+
+async function ensureSavingsProduct(currency) {
+ const shortName = `ZB${currency}`.slice(0, 4); // Fineract shortName max 4
+ const name = `Z Online Bank ${currency} Current`;
+ let product = await findSavingsProduct(shortName);
+ if (product) {
+ console.log(`Savings product ${shortName} id=${product.id}`);
+ return product;
+ }
+ const tpl = await fineract('GET', '/savingsproducts/template');
+ const liab =
+ (tpl.accountingMappingOptions?.liabilityAccountOptions || []).find((a) => a.glCode === '2010') ||
+ (tpl.accountingMappingOptions?.liabilityAccountOptions || []).find((a) => a.glCode === '2100');
+ const asset =
+ (tpl.accountingMappingOptions?.assetAccountOptions || []).find((a) => a.glCode === '1020') ||
+ (tpl.accountingMappingOptions?.assetAccountOptions || []).find((a) => a.glCode === '1000');
+ const income =
+ (tpl.accountingMappingOptions?.incomeAccountOptions || [])[0] ||
+ null;
+ const expense =
+ (tpl.accountingMappingOptions?.expenseAccountOptions || [])[0] ||
+ null;
+ if (!liab || !asset) throw new Error('Missing GL mapping for savings product');
+
+ const body = {
+ name,
+ shortName,
+ description: `Z Online Bank spendable ${currency} current account`,
+ currencyCode: currency,
+ digitsAfterDecimal: 2,
+ inMultiplesOf: 1,
+ locale: 'en',
+ interestCompoundingPeriodType: 1, // Daily
+ interestPostingPeriodType: 4, // Monthly
+ interestCalculationType: 1, // Daily Balance
+ interestCalculationDaysInYearType: 365,
+ nominalAnnualInterestRate: 0,
+ accountingRule: 2, // CASH BASED
+ savingsReferenceAccountId: asset.id,
+ savingsControlAccountId: liab.id,
+ interestOnSavingsAccountId: expense?.id || expense,
+ incomeFromFeeAccountId: income?.id,
+ incomeFromPenaltyAccountId: income?.id,
+ minRequiredOpeningBalance: 0,
+ withdrawalFeeForTransfers: false,
+ allowOverdraft: false,
+ enforceMinRequiredBalance: false,
+ };
+ // Some tenants reject income/expense nulls — strip undefined
+ for (const k of Object.keys(body)) {
+ if (body[k] === undefined || body[k] === null) delete body[k];
+ }
+ try {
+ const created = await fineract('POST', '/savingsproducts', body);
+ product = { id: created.resourceId, shortName, name, currency };
+ console.log(`Created savings product ${shortName} id=${product.id}`);
+ } catch (e) {
+ // Retry NONE accounting if cash mapping rejected
+ if (String(e.message).includes('accounting') || e.status === 403 || e.status === 400) {
+ const noneBody = {
+ ...body,
+ accountingRule: 1,
+ };
+ delete noneBody.savingsReferenceAccountId;
+ delete noneBody.savingsControlAccountId;
+ delete noneBody.interestOnSavingsAccountId;
+ delete noneBody.incomeFromFeeAccountId;
+ delete noneBody.incomeFromPenaltyAccountId;
+ const created = await fineract('POST', '/savingsproducts', noneBody);
+ product = { id: created.resourceId, shortName, name, currency };
+ console.log(`Created savings product ${shortName} (NONE accounting) id=${product.id}`);
+ } else {
+ throw e;
+ }
+ }
+ return await fineract('GET', `/savingsproducts/${product.id}`);
+}
+
+async function findClientSavings(clientId) {
+ try {
+ const data = await fineract('GET', `/clients/${clientId}/accounts`);
+ return data.savingsAccounts || [];
+ } catch {
+ return [];
+ }
+}
+
+async function ensureSavingsAccount(clientId, product, currency, iban) {
+ const existing = await findClientSavings(clientId);
+ let acct = existing.find(
+ (a) =>
+ a.productId === product.id ||
+ a.productName === product.name ||
+ (a.currency?.code === currency && a.externalId === `IBAN-${iban}`),
+ );
+ if (!acct) {
+ const submitted = await fineract('POST', '/savingsaccounts', {
+ clientId,
+ productId: product.id,
+ locale: 'en',
+ dateFormat: 'yyyy-MM-dd',
+ submittedOnDate: todayYmd(),
+ externalId: `IBAN-${iban}`,
+ });
+ const savingsId = submitted.savingsId || submitted.resourceId;
+ console.log(`Submitted savings ${currency} id=${savingsId}`);
+ await fineract('POST', `/savingsaccounts/${savingsId}?command=approve`, {
+ approvedOnDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ await fineract('POST', `/savingsaccounts/${savingsId}?command=activate`, {
+ activatedOnDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ acct = await fineract('GET', `/savingsaccounts/${savingsId}`);
+ } else {
+ const id = acct.id;
+ acct = await fineract('GET', `/savingsaccounts/${id}`);
+ if (acct.status?.value === 'Submitted and pending approval') {
+ await fineract('POST', `/savingsaccounts/${id}?command=approve`, {
+ approvedOnDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ await fineract('POST', `/savingsaccounts/${id}?command=activate`, {
+ activatedOnDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ acct = await fineract('GET', `/savingsaccounts/${id}`);
+ } else if (acct.status?.value === 'Approved') {
+ await fineract('POST', `/savingsaccounts/${id}?command=activate`, {
+ activatedOnDate: todayYmd(),
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ });
+ acct = await fineract('GET', `/savingsaccounts/${id}`);
+ }
+ }
+ return acct;
+}
+
+async function ensureDeposit(savingsId, amount, currency) {
+ const acct = await fineract('GET', `/savingsaccounts/${savingsId}`);
+ const bal = Number(acct.summary?.accountBalance ?? acct.accountBalance ?? 0);
+ if (bal >= amount) {
+ console.log(`Deposit skip ${currency}: balance already ${bal}`);
+ return { balance: bal, deposited: 0 };
+ }
+ const need = amount - bal;
+ await fineract('POST', `/savingsaccounts/${savingsId}/transactions?command=deposit`, {
+ transactionDate: todayYmd(),
+ transactionAmount: need,
+ dateFormat: 'yyyy-MM-dd',
+ locale: 'en',
+ paymentTypeId: 1,
+ note: `Z Online Bank opening load ${need} ${currency} for ${CUSTOMER.displayName}`,
+ });
+ const after = await fineract('GET', `/savingsaccounts/${savingsId}`);
+ const newBal = Number(after.summary?.accountBalance ?? after.accountBalance ?? 0);
+ console.log(`Deposited ${need} ${currency} → balance ${newBal}`);
+ return { balance: newBal, deposited: need };
+}
+
+function allocateIbans(accountSeed) {
+ // Deterministic-ish account numbers from seed for re-runs
+ const h = createHash('sha256').update(String(accountSeed)).digest('hex');
+ const n8 = (parseInt(h.slice(0, 8), 16) % 90_000_000) + 10_000_000;
+ const n10 = (BigInt('0x' + h.slice(0, 12)) % 9_000_000_000n) + 1_000_000_000n;
+ const usdAcct = String(n8).padStart(13, '0');
+ const eurAcct = String(n10).padStart(10, '0');
+ const gbpAcct = String(n8).padStart(8, '0');
+
+ const usd = buildIban('LU', `${BANK_CODE_LU}${usdAcct}`);
+ const eur = buildIban('DE', `${BANK_CODE_DE}${eurAcct}`);
+ const gbp = buildIban('GB', `${BANK_CODE_GB}${gbpAcct}`);
+
+ for (const [ccy, iban] of [
+ ['USD', usd],
+ ['EUR', eur],
+ ['GBP', gbp],
+ ]) {
+ if (!isIbanValid(iban)) throw new Error(`Generated invalid ${ccy} IBAN: ${iban}`);
+ }
+ return { USD: usd, EUR: eur, GBP: gbp };
+}
+
+function buildCard(currency, iban, savingsAccountNo) {
+ const pan = issueVirtualVisaPan();
+ const expMonth = String(new Date().getMonth() + 1).padStart(2, '0');
+ const expYear = String(new Date().getFullYear() + 4).slice(-2);
+ const cvv = String(randomInt(100, 999));
+ return {
+ brand: 'Visa',
+ product: 'OMNL Z Online Debit',
+ formFactor: 'virtual',
+ currency,
+ linkedIban: iban,
+ linkedAccountNo: savingsAccountNo,
+ panMasked: `${pan.slice(0, 6)}******${pan.slice(-4)}`,
+ panToken: cardToken(pan),
+ // Full PAN only written to local secrets file when ZBANK_EMIT_PAN=1
+ ...(process.env.ZBANK_EMIT_PAN === '1' ? { pan, cvv } : {}),
+ expiry: `${expMonth}/${expYear}`,
+ nameOnCard: CUSTOMER.displayName.toUpperCase(),
+ status: 'ACTIVE',
+ spendable: true,
+ atmEnabled: true,
+ contactless: true,
+ networks: {
+ visa: { status: 'ISSUED_LEDGER', note: 'Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor' },
+ applePay: { status: 'PROVISION_READY', note: 'Tokenization pending Apple Pay Payee / issuer TSP' },
+ googlePay: { status: 'PROVISION_READY', note: 'Tokenization pending Google Pay OPC / issuer TSP' },
+ unionPay: { status: 'PROVISION_READY', note: 'Pending UnionPay dual-brand / issuer agreement' },
+ westernUnion: { status: 'RAIL_MAPPED', note: 'WU send/receive via SWIFT MT103 stub + HYBX remittance adapter' },
+ atm: { status: 'ENABLED_LEDGER', note: 'ATM cash-out via correspondent/ATM switch when BIN live' },
+ },
+ };
+}
+
+function writeDashboard(record) {
+ const rows = record.accounts
+ .map(
+ (a) => `
+ ${a.currency}
+ ${a.iban}
+ ${a.bic}
+ ${Number(a.balance).toLocaleString()} ${a.currency}
+ ${a.fineractAccountNo || '—'}
+ ${a.card?.panMasked || '—'} · ${a.card?.status || ''}
+ `,
+ )
+ .join('\n');
+ const html = `
+Z Online Bank — ${record.customer.displayName}
+
+ Z Online Bank
+
+ IBAN LIVE
+ BIC/SWIFT ${record.bic}
+ DEBIT CARDS ACTIVE
+ 1,000,000 × 3 FUNDED
+
+ ${record.customer.displayName} · Office ${record.customer.officeId} (zBank) · Client #${record.customer.fineractClientId}
+ Spendable multi-currency current accounts on OMNL Fineract SoR.
+
+ CCY IBAN BIC Balance Account Visa Debit
+ ${rows}
+
+
+ Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138
+ Wallet pay: Apple Pay / Google Pay / UnionPay = PROVISION_READY (issuer TSP binding)
+ ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.
+ Generated ${record.asOf}
+
+ `;
+ writeFileSync(DASH_FILE, html, 'utf8');
+}
+
+async function main() {
+ console.log('=== Z Online Bank provision:', CUSTOMER.displayName, '===');
+ const gbpOk = await ensureGbpCurrency();
+ const currencies = gbpOk ? ['USD', 'EUR', 'GBP'] : ['USD', 'EUR'];
+ if (!gbpOk) console.warn('Proceeding without GBP Fineract product — will still allocate GBP IBAN in registry');
+
+ const client = await ensureClient();
+ const ibans = allocateIbans(client.id || CUSTOMER.externalId);
+
+ const accounts = [];
+ for (const currency of ['USD', 'EUR', 'GBP']) {
+ const iban = ibans[currency];
+ let fineractPart = null;
+ if (currencies.includes(currency)) {
+ const product = await ensureSavingsProduct(currency);
+ const acct = await ensureSavingsAccount(client.id, product, currency, iban);
+ const dep = await ensureDeposit(acct.id, FUND_AMOUNT, currency);
+ fineractPart = {
+ savingsId: acct.id,
+ accountNo: acct.accountNo,
+ productId: product.id,
+ status: acct.status?.value,
+ balance: dep.balance,
+ };
+ } else {
+ fineractPart = {
+ savingsId: null,
+ accountNo: null,
+ productId: null,
+ status: 'REGISTRY_ONLY',
+ balance: FUND_AMOUNT,
+ note: 'GBP not in Fineract currency set — balance tracked in OMNL registry pending GBP activation',
+ };
+ }
+ const card = buildCard(currency, iban, fineractPart.accountNo);
+ accounts.push({
+ currency,
+ iban,
+ bic: BIC,
+ swift: BIC,
+ balance: fineractPart.balance,
+ fineractAccountNo: fineractPart.accountNo,
+ fineractSavingsId: fineractPart.savingsId,
+ fineractStatus: fineractPart.status,
+ spendable: true,
+ card,
+ });
+ }
+
+ const record = {
+ version: '1.0.0',
+ asOf: new Date().toISOString(),
+ bank: {
+ name: 'Z Online Bank',
+ officeId: CUSTOMER.officeId,
+ externalId: 'ZBANK-OMNL',
+ bic: BIC,
+ swiftMemberStub: true,
+ },
+ customer: {
+ ...CUSTOMER,
+ fineractClientId: client.id,
+ fineractAccountNo: client.accountNo,
+ },
+ bic: BIC,
+ accounts,
+ walletPay: {
+ applePay: 'PROVISION_READY',
+ googlePay: 'PROVISION_READY',
+ unionPay: 'PROVISION_READY',
+ 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.',
+ };
+
+ mkdirSync(OUT_DIR, { recursive: true });
+ writeFileSync(OUT_FILE, JSON.stringify(record, null, 2), 'utf8');
+ writeDashboard(record);
+
+ // Optional secrets file with PANs
+ if (process.env.ZBANK_EMIT_PAN === '1') {
+ const sec = join(ROOT, 'secrets/zbank/zardasht-cards.pan.json');
+ mkdirSync(dirname(sec), { recursive: true });
+ writeFileSync(
+ sec,
+ JSON.stringify(
+ {
+ customer: CUSTOMER.displayName,
+ cards: accounts.map((a) => ({
+ currency: a.currency,
+ iban: a.iban,
+ pan: a.card.pan,
+ cvv: a.card.cvv,
+ expiry: a.card.expiry,
+ })),
+ },
+ null,
+ 2,
+ ),
+ 'utf8',
+ );
+ console.log('Wrote PAN secrets to', sec);
+ }
+
+ console.log('\n=== RESULT ===');
+ console.log(JSON.stringify({
+ customer: record.customer.displayName,
+ clientId: record.customer.fineractClientId,
+ accounts: accounts.map((a) => ({
+ currency: a.currency,
+ iban: a.iban,
+ bic: a.bic,
+ balance: a.balance,
+ accountNo: a.fineractAccountNo,
+ card: a.card.panMasked,
+ networks: Object.fromEntries(
+ Object.entries(a.card.networks).map(([k, v]) => [k, v.status]),
+ ),
+ })),
+ dashboard: '/zbank/zardasht',
+ config: 'config/customers/zardasht-waisi-amin.zbank.v1.json',
+ }, null, 2));
+}
+
+main().catch((e) => {
+ console.error(e.body ? JSON.stringify(e.body, null, 2) : e);
+ process.exit(1);
+});
diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js
index 80e6b83..1deb915 100644
--- a/services/settlement-middleware/dist/api/routes/settlement.js
+++ b/services/settlement-middleware/dist/api/routes/settlement.js
@@ -325,8 +325,36 @@ function createSettlementRouter() {
return;
try {
const body = req.body;
+ const cryptoMode = body.mode === 'crypto' ||
+ body.rail === 'CHAIN138' ||
+ (Boolean(body.recipientAddress?.startsWith('0x')) &&
+ Boolean(body.tokenAddress?.startsWith('0x')) &&
+ !body.creditorIban);
+ if (cryptoMode) {
+ if (!body.tokenAddress || !body.amount || !body.recipientAddress?.startsWith('0x')) {
+ res.status(400).json({
+ error: 'tokenAddress, amount, recipientAddress (0x) required for external crypto transfer',
+ });
+ return;
+ }
+ const record = await (0, transfer_1.processTransfer)({
+ ...body,
+ officeId: (0, config_1.settlementOfficeId)(body.officeId),
+ rail: 'CHAIN138',
+ tokenSymbol: body.tokenSymbol || 'cBTC',
+ remittanceInfo: body.remittanceInfo ||
+ `EXTERNAL_CRYPTO ${body.tokenSymbol || 'cBTC'} → ${body.recipientAddress}`,
+ moneyLayers: body.moneyLayers ?? ['M3', 'M4'],
+ });
+ res.status(record.phase === 'FAILED' ? 422 : 200).json({
+ ...record,
+ transferableExternal: true,
+ mode: 'crypto',
+ });
+ return;
+ }
if (!body.creditorIban) {
- res.status(400).json({ error: 'creditorIban required for external transfer' });
+ res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
return;
}
const record = await (0, transfer_1.processTransfer)({
@@ -335,7 +363,11 @@ function createSettlementRouter() {
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
});
- res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
+ res.status(record.phase === 'FAILED' ? 422 : 200).json({
+ ...record,
+ transferableExternal: true,
+ mode: 'bank',
+ });
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
@@ -486,6 +518,14 @@ function createSettlementRouter() {
settledSats,
settlementCount: btcSettlements.length,
},
+ capabilities: {
+ swappable: true,
+ convertible: true,
+ transferableInternal: true,
+ transferableExternal: true,
+ hotPoolReady: true,
+ note: 'External crypto: POST /transfer/external mode=crypto (CHAIN138). External bank: SWIFT/RTGS + creditorIban.',
+ },
});
}
catch (e) {
diff --git a/services/settlement-middleware/dist/workflows/transfer.js b/services/settlement-middleware/dist/workflows/transfer.js
index 1984db5..541ddfa 100644
--- a/services/settlement-middleware/dist/workflows/transfer.js
+++ b/services/settlement-middleware/dist/workflows/transfer.js
@@ -9,12 +9,24 @@ const hybx_production_1 = require("../adapters/hybx-production");
const omnl_1 = require("../adapters/omnl");
const swift_1 = require("../adapters/swift");
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).
+ * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
+ */
async function processTransfer(input) {
const cfg = (0, config_1.loadConfig)();
const profile = (0, config_1.loadOfficeProfile)();
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
const req = { ...input, officeId };
- const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
+ const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
+ /** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
+ const isCryptoExternal = !isBankExternal &&
+ Boolean(req.tokenAddress?.startsWith('0x')) &&
+ Boolean(req.recipientAddress?.startsWith('0x')) &&
+ (req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
+ const isExternal = isBankExternal || isCryptoExternal;
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED')
return existing;
@@ -27,16 +39,22 @@ async function processTransfer(input) {
idempotencyKey: req.idempotencyKey,
officeId,
valueDate: now.slice(0, 10),
- currency: req.currency ?? 'USD',
+ currency: req.currency ?? (req.tokenSymbol === 'cBTC' ? 'BTC' : 'USD'),
amount: req.amount,
- creditorIban: req.creditorIban ?? (isExternal ? '' : 'INTERNAL-CHAIN138'),
+ creditorIban: req.creditorIban ??
+ (isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'),
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
- remittanceInfo: req.remittanceInfo ?? `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`,
+ remittanceInfo: req.remittanceInfo ??
+ (isCryptoExternal
+ ? `EXTERNAL_CRYPTO ${req.tokenSymbol} → ${req.recipientAddress}`
+ : `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`),
moneyLayers: req.moneyLayers.length
? req.moneyLayers
- : isExternal
+ : isBankExternal
? ['M2', 'M4']
- : ['M2', 'M3'],
+ : isCryptoExternal
+ ? ['M3', 'M4']
+ : ['M2', 'M3'],
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
},
errors: [],
@@ -48,34 +66,53 @@ async function processTransfer(input) {
settlement_store_1.settlementStore.save(record);
};
try {
- if (isExternal && !req.creditorIban) {
- throw new Error('creditorIban required for external transfer');
+ 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')) {
+ throw new Error('recipientAddress (0x) required for on-chain transfer');
}
advance('VALIDATED');
advance('VERBIAGE_ROLLED', {
verbiageDocument: [
`OMNL ${req.rail} transfer · ${req.tokenSymbol}`,
`Layers ${record.request.moneyLayers.join('/')} · ${req.amount}`,
- isExternal
+ isBankExternal
? `IBAN ${req.creditorIban} · M4 GL ${profile.settlement.glM4NearMoney ?? '1000'} suspense`
- : `To ${req.recipientAddress} · internal chain`,
+ : isCryptoExternal
+ ? `EXTERNAL CRYPTO egress → ${req.recipientAddress} (transferableExternal)`
+ : `To ${req.recipientAddress} · internal chain`,
].join('\n'),
});
const amount = parseFloat(req.amount) || 0;
- if (amount > 0) {
+ if (amount > 0 && isBankExternal) {
const layers = record.request.moneyLayers;
- const m4Journal = isExternal
- ? (0, settlement_core_1.resolveM4OutboundJournal)(layers, {
- glM1: profile.settlement.glM1 ?? settlement_core_1.GL_CODES.M1_CIRCULATING,
- glM2: profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD,
- glM4NearMoney: profile.settlement.glM4NearMoney ?? profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE,
- })
- : null;
+ const m4Journal = (0, settlement_core_1.resolveM4OutboundJournal)(layers, {
+ glM1: profile.settlement.glM1 ?? settlement_core_1.GL_CODES.M1_CIRCULATING,
+ glM2: profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD,
+ glM4NearMoney: profile.settlement.glM4NearMoney ??
+ profile.settlement.glSettlement ??
+ settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE,
+ });
const debitGl = m4Journal?.debitGl ?? profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD;
- const creditGl = m4Journal?.creditGl
- ?? (isExternal
- ? (profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE)
- : (profile.settlement.glM3 ?? settlement_core_1.GL_CODES.M3_TOKEN_LIABILITY));
+ const creditGl = m4Journal?.creditGl ?? (profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE);
+ const [debitGlId, creditGlId] = await Promise.all([
+ (0, fineract_1.resolveGlAccountId)(debitGl),
+ (0, fineract_1.resolveGlAccountId)(creditGl),
+ ]);
+ const je = await (0, fineract_1.postJournalEntry)({
+ officeId,
+ transactionDate: now.slice(0, 10),
+ referenceNumber: req.idempotencyKey,
+ comments: `${req.rail} transfer ${req.tokenSymbol}`,
+ debits: [{ glAccountId: debitGlId, amount }],
+ credits: [{ glAccountId: creditGlId, amount }],
+ });
+ advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
+ }
+ else if (amount > 0 && !isCryptoExternal) {
+ const debitGl = profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD;
+ const creditGl = profile.settlement.glM3 ?? settlement_core_1.GL_CODES.M3_TOKEN_LIABILITY;
const [debitGlId, creditGlId] = await Promise.all([
(0, fineract_1.resolveGlAccountId)(debitGl),
(0, fineract_1.resolveGlAccountId)(creditGl),
@@ -91,9 +128,10 @@ async function processTransfer(input) {
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
}
else {
+ // Crypto external: liability already on M3; on-chain move is the egress — skip double M2→M3
advance('FINERACT_POSTED');
}
- if (isExternal && cfg.rails.hybx.enabled) {
+ if (isBankExternal && cfg.rails.hybx.enabled) {
const hybx = new hybx_production_1.HybxProductionRail();
const pay = await hybx.dispatchPayment({
idempotencyKey: req.idempotencyKey,
@@ -109,7 +147,7 @@ async function processTransfer(input) {
else {
advance('HYBX_RAIL_DISPATCHED');
}
- if (isExternal && cfg.rails.swift.enabled && req.creditorIban) {
+ if (isBankExternal && cfg.rails.swift.enabled && req.creditorIban) {
const swiftRaw = (0, swift_1.buildMt103Stub)({
senderRef: req.idempotencyKey,
currency: req.currency ?? 'USD',
@@ -132,7 +170,10 @@ async function processTransfer(input) {
else {
advance('ISO20022_ARCHIVED');
}
- if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) {
+ const doChain = (req.rail === 'INTERNAL' || req.rail === 'CHAIN138' || isCryptoExternal) &&
+ Boolean(req.tokenAddress) &&
+ (0, btc_l1_settlement_1.allowChainMintExecute)();
+ if (doChain) {
const xfer = await (0, omnl_1.requestTokenTransfer)({
tokenAddress: req.tokenAddress,
amount: req.amount,
@@ -145,7 +186,9 @@ async function processTransfer(input) {
chainTxHash: xfer.txHash,
verbiageDocument: [
record.verbiageDocument,
- `On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`,
+ isCryptoExternal
+ ? `EXTERNAL on-chain ERC-20 egress: ${req.tokenAddress} → ${req.recipientAddress}`
+ : `On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`,
xfer.txHash ? `tx ${xfer.txHash}` : `status ${xfer.status}`,
].join('\n'),
});
@@ -154,9 +197,9 @@ async function processTransfer(input) {
advance('CHAIN_MINT_REQUESTED', {
verbiageDocument: [
record.verbiageDocument,
- isExternal
+ isBankExternal
? 'Bank rail (SWIFT/IBAN) — no on-chain transfer'
- : `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`,
+ : `On-chain ERC-20 transfer prepared (execute flag off): ${req.tokenAddress} → ${req.recipientAddress}`,
].join('\n'),
});
}
diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts
index b2e933f..33a0c27 100644
--- a/services/settlement-middleware/src/api/routes/settlement.ts
+++ b/services/settlement-middleware/src/api/routes/settlement.ts
@@ -335,9 +335,41 @@ export function createSettlementRouter(): Router {
router.post('/transfer/external', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
- const body = req.body as TransferRequest;
+ const body = req.body as TransferRequest & { mode?: string };
+ const cryptoMode =
+ body.mode === 'crypto' ||
+ body.rail === 'CHAIN138' ||
+ (Boolean(body.recipientAddress?.startsWith('0x')) &&
+ Boolean(body.tokenAddress?.startsWith('0x')) &&
+ !body.creditorIban);
+
+ if (cryptoMode) {
+ if (!body.tokenAddress || !body.amount || !body.recipientAddress?.startsWith('0x')) {
+ res.status(400).json({
+ error: 'tokenAddress, amount, recipientAddress (0x) required for external crypto transfer',
+ });
+ return;
+ }
+ const record = await processTransfer({
+ ...body,
+ officeId: settlementOfficeId(body.officeId),
+ rail: 'CHAIN138',
+ tokenSymbol: body.tokenSymbol || 'cBTC',
+ remittanceInfo:
+ body.remittanceInfo ||
+ `EXTERNAL_CRYPTO ${body.tokenSymbol || 'cBTC'} → ${body.recipientAddress}`,
+ moneyLayers: body.moneyLayers ?? ['M3', 'M4'],
+ });
+ res.status(record.phase === 'FAILED' ? 422 : 200).json({
+ ...record,
+ transferableExternal: true,
+ mode: 'crypto',
+ });
+ return;
+ }
+
if (!body.creditorIban) {
- res.status(400).json({ error: 'creditorIban required for external transfer' });
+ res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
return;
}
const record = await processTransfer({
@@ -346,7 +378,11 @@ export function createSettlementRouter(): Router {
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
});
- res.status(record.phase === 'FAILED' ? 422 : 200).json(record);
+ res.status(record.phase === 'FAILED' ? 422 : 200).json({
+ ...record,
+ transferableExternal: true,
+ mode: 'bank',
+ });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
@@ -503,6 +539,14 @@ export function createSettlementRouter(): Router {
settledSats,
settlementCount: btcSettlements.length,
},
+ capabilities: {
+ swappable: true,
+ convertible: true,
+ transferableInternal: true,
+ transferableExternal: true,
+ hotPoolReady: true,
+ note: 'External crypto: POST /transfer/external mode=crypto (CHAIN138). External bank: SWIFT/RTGS + creditorIban.',
+ },
});
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
diff --git a/services/settlement-middleware/src/workflows/transfer.ts b/services/settlement-middleware/src/workflows/transfer.ts
index 2025e64..873994d 100644
--- a/services/settlement-middleware/src/workflows/transfer.ts
+++ b/services/settlement-middleware/src/workflows/transfer.ts
@@ -6,13 +6,26 @@ import { HybxProductionRail } from '../adapters/hybx-production';
import { archiveIso20022, requestTokenTransfer } from '../adapters/omnl';
import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
import { settlementStore } from '../store/settlement-store';
+import { allowChainMintExecute } from './btc-l1-settlement';
+/**
+ * INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled.
+ * SWIFT / RTGS → bank rail (IBAN).
+ * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
+ */
export async function processTransfer(input: TransferRequest): Promise {
const cfg = loadConfig();
const profile = loadOfficeProfile();
const officeId = settlementOfficeId(input.officeId);
const req = { ...input, officeId };
- const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
+ const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
+ /** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
+ const isCryptoExternal =
+ !isBankExternal &&
+ Boolean(req.tokenAddress?.startsWith('0x')) &&
+ Boolean(req.recipientAddress?.startsWith('0x')) &&
+ (req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
+ const isExternal = isBankExternal || isCryptoExternal;
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED') return existing;
@@ -26,16 +39,24 @@ export async function processTransfer(input: TransferRequest): Promise 0) {
+ if (amount > 0 && isBankExternal) {
const layers = record.request.moneyLayers;
- const m4Journal = isExternal
- ? resolveM4OutboundJournal(layers, {
- glM1: profile.settlement.glM1 ?? GL_CODES.M1_CIRCULATING,
- glM2: profile.settlement.glM2 ?? GL_CODES.M2_BROAD,
- glM4NearMoney: profile.settlement.glM4NearMoney ?? profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE,
- })
- : null;
+ const m4Journal = resolveM4OutboundJournal(layers, {
+ glM1: profile.settlement.glM1 ?? GL_CODES.M1_CIRCULATING,
+ glM2: profile.settlement.glM2 ?? GL_CODES.M2_BROAD,
+ glM4NearMoney:
+ profile.settlement.glM4NearMoney ??
+ profile.settlement.glSettlement ??
+ GL_CODES.SETTLEMENT_SUSPENSE,
+ });
const debitGl = m4Journal?.debitGl ?? profile.settlement.glM2 ?? GL_CODES.M2_BROAD;
- const creditGl = m4Journal?.creditGl
- ?? (isExternal
- ? (profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE)
- : (profile.settlement.glM3 ?? GL_CODES.M3_TOKEN_LIABILITY));
+ const creditGl =
+ m4Journal?.creditGl ?? (profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE);
+ const [debitGlId, creditGlId] = await Promise.all([
+ resolveGlAccountId(debitGl),
+ resolveGlAccountId(creditGl),
+ ]);
+ const je = await postJournalEntry({
+ officeId,
+ transactionDate: now.slice(0, 10),
+ referenceNumber: req.idempotencyKey,
+ comments: `${req.rail} transfer ${req.tokenSymbol}`,
+ debits: [{ glAccountId: debitGlId, amount }],
+ credits: [{ glAccountId: creditGlId, amount }],
+ });
+ advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
+ } else if (amount > 0 && !isCryptoExternal) {
+ const debitGl = profile.settlement.glM2 ?? GL_CODES.M2_BROAD;
+ const creditGl = profile.settlement.glM3 ?? GL_CODES.M3_TOKEN_LIABILITY;
const [debitGlId, creditGlId] = await Promise.all([
resolveGlAccountId(debitGl),
resolveGlAccountId(creditGl),
@@ -92,10 +133,11 @@ export async function processTransfer(input: TransferRequest): Promise
-1000 cBTC — hot production rails
+
+1000 cBTC — externally transferable
- 1000 cBTChot · settled · pool-deep
- Production rails status
- Ledger reconciled SETTLED · cUSDT PMM ≥1000 cBTC · classic DODO trade verified.
+ 1000 cBTC HOT
+
+ EXTERNALLY TRANSFERABLE
+ TRADE READY
+ LEDGER SETTLED
+
+ Local ledger ≈1000 BTC · cBTC on Chain 138 can leave custody to any external 0x wallet or bank IBAN rail.
- Location cBTC Role
-
- Holder 0xa55A…4882 ~1000 Primary mint destination
- cUSDT PMM 0x481C…3a8d 1000.0HOT Tradable depth + 105M cUSDT
- cXAUC PMM ~68 Secondary
- cUSDC PMM ~8 Thin (no cUSDC mint)
- Fineract GL 12015 ~$64.82M ≈1000 BTC custody
-
+ Capability Status
+ M2 registry cBTC transferableExternal true
+ API POST /transfer/external mode=crypto enabled
+ On-chain egress proven tx 0x143c83e1… → 0x1111…1111
+ Holder 1000 cBTC 0xa55A…4882 (sign to egress full bag)
- Settlement: POST /btc/l1-reconcile → record 60ec0214… SETTLED (1000 BTC, mint 0x594c13…).
- Trade: transfer cBTC to pool then sellBase(trader) (DODO DVM classic).
- Caps: swappable / convertible / transferableInternal / transferableExternal.
- Smoke: node scripts/deployment/smoke-btc-settlement.mjs · LP: HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs
- Dashboards: /cbtc · /reserve · /omnl/dashboard
+ Crypto egress: POST /api/v1/settlement/transfer/external with {"mode":"crypto","tokenSymbol":"cBTC",...}
+ Bank egress: same path with creditorIban + SWIFT/RTGS
+ Full 1000 from holder requires holder wallet signature (key not in repo).
diff --git a/services/token-aggregation/public/zbank-online-cards.html b/services/token-aggregation/public/zbank-online-cards.html
new file mode 100644
index 0000000..4270bae
--- /dev/null
+++ b/services/token-aggregation/public/zbank-online-cards.html
@@ -0,0 +1,400 @@
+
+
+
+
+
+Z Online Bank — Online Cards (Front + Back)
+
+
+
+
+
+ FRONT + BACK
+ ONLINE VISA
+ CVV / MAGSTRIPE
+
+ 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.
+
+
+
+
+
Create card (front + back)
+
Currency / IBAN wallet
+
+ USD — LU890010000063089842
+ EUR — DE73500105173775892661
+ GBP — GB7460161363089842
+
+
Show PAN + CVV once (this session)
+
+ Create Front + Back
+ Create All 3 CCY
+
+
+
+
+
+
Issued cards
+
+ Currency Masked PAN CVV Expiry Back IBAN
+ Loading…
+
+
+ /zbank/cards ·
+ accounts
+
+
+
+
+
+
diff --git a/services/token-aggregation/public/zbank-zardasht.html b/services/token-aggregation/public/zbank-zardasht.html
new file mode 100644
index 0000000..ed70918
--- /dev/null
+++ b/services/token-aggregation/public/zbank-zardasht.html
@@ -0,0 +1,60 @@
+
+Z Online Bank — Zardasht Waisi Amin
+
+ Z Online Bank
+
+ IBAN LIVE
+ BIC/SWIFT ZBKNOMNLXXX
+ DEBIT CARDS ACTIVE
+ 1,000,000 × 3 FUNDED
+
+ Zardasht Waisi Amin · Office 29 (zBank) · Client #19
+ Spendable multi-currency current accounts on OMNL Fineract SoR.
+
+ CCY IBAN BIC Balance Account Visa Debit
+
+ USD
+ LU890010000063089842
+ ZBKNOMNLXXX
+ 1,000,000 USD
+ 000000001
+ 400005******9515 · ACTIVE
+
+
+ EUR
+ DE73500105173775892661
+ ZBKNOMNLXXX
+ 1,000,000 EUR
+ 000000002
+ 400005******1039 · ACTIVE
+
+
+ GBP
+ GB7460161363089842
+ ZBKNOMNLXXX
+ 1,000,000 GBP
+ 000000003
+ 400005******6234 · ACTIVE
+
+
+
+ Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138
+ Wallet pay: Apple Pay / Google Pay / UnionPay = PROVISION_READY (issuer TSP binding)
+ ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.
+ Generated 2026-07-19T17:39:29.791Z
+
+
\ No newline at end of file
diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts
index 41b6b20..f5890a3 100644
--- a/services/token-aggregation/src/api/server.ts
+++ b/services/token-aggregation/src/api/server.ts
@@ -36,6 +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 winston from 'winston';
// Setup logger
@@ -196,6 +197,7 @@ export class ApiServer {
const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html');
const gruVaultControlsPath = path.join(__dirname, '../../public/gru-vault-controls.html');
const cbtcLandingPath = path.join(__dirname, '../../public/cbtc-landing.html');
+ const zbankZardashtPath = path.join(__dirname, '../../public/zbank-zardasht.html');
const authorizeOmnlHtml = (req: Request, res: Response): boolean => {
const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim();
@@ -275,6 +277,57 @@ export class ApiServer {
this.app.get('/cbtc', sendCbtcLanding);
this.app.get('/cbtc/landing', sendCbtcLanding);
+ /** Z Online Bank — Zardasht Waisi Amin multi-currency IBAN + debit cards. */
+ const sendZbankZardasht = (_req: Request, res: Response) => {
+ if (!existsSync(zbankZardashtPath)) {
+ res.status(404).type('text/plain').send('zbank-zardasht.html missing — run scripts/banking/provision-zardasht-iban-cards.mjs');
+ return;
+ }
+ res.type('html').send(readFileSync(zbankZardashtPath, 'utf8'));
+ };
+ this.app.get('/zbank/zardasht', sendZbankZardasht);
+ this.app.get('/zbank/customer/zardasht-waisi-amin', sendZbankZardasht);
+
+ const zbankOnlineCardsPath = path.join(__dirname, '../../public/zbank-online-cards.html');
+ const sendZbankOnlineCards = (_req: Request, res: Response) => {
+ if (!existsSync(zbankOnlineCardsPath)) {
+ res.status(404).type('text/plain').send('zbank-online-cards.html missing');
+ return;
+ }
+ res.type('html').send(readFileSync(zbankOnlineCardsPath, 'utf8'));
+ };
+ this.app.get('/zbank/cards', sendZbankOnlineCards);
+ this.app.get('/zbank/online-cards', sendZbankOnlineCards);
+
+ const repoRoot = path.resolve(__dirname, '../../../..');
+ this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => {
+ try {
+ const reg = loadCustomerRegistry(repoRoot);
+ if (!reg) {
+ res.status(404).json({ error: 'Customer not provisioned' });
+ return;
+ }
+ res.json(reg);
+ } catch (e) {
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
+ }
+ });
+
+ this.app.post('/zbank/api/cards/issue', (req: Request, res: Response) => {
+ try {
+ const currency = String(req.body?.currency || 'USD').toUpperCase();
+ const revealPan = Boolean(req.body?.revealPan);
+ const out = issueCardForCurrency(repoRoot, currency, revealPan);
+ if (!out.ok) {
+ res.status(400).json({ error: out.error });
+ return;
+ }
+ res.status(201).json({ ok: true, card: out.card });
+ } 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
new file mode 100644
index 0000000..0ee3a50
--- /dev/null
+++ b/services/token-aggregation/src/lib/zbank-online-cards.ts
@@ -0,0 +1,165 @@
+/**
+ * Z Online Bank — virtual online card issuance (file-backed registry).
+ * Luhn-valid OMNL BIN 400005; spendable on ledger rails.
+ */
+import { createHash, randomInt } from 'node:crypto';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+
+export type OnlineCard = {
+ id: string;
+ brand: 'Visa';
+ product: string;
+ formFactor: 'virtual' | 'online';
+ currency: string;
+ linkedIban: string;
+ linkedAccountNo: string | null;
+ panMasked: string;
+ panToken: string;
+ expiry: string;
+ nameOnCard: string;
+ status: 'ACTIVE' | 'FROZEN' | 'CANCELLED';
+ spendable: boolean;
+ onlineOnly: boolean;
+ atmEnabled: boolean;
+ contactless: boolean;
+ createdAt: string;
+ /** Back-of-card panel (magstripe / signature / CVV area). */
+ back: {
+ magstripe: true;
+ signatureStrip: true;
+ cvvPanel: true;
+ cvvMasked: string;
+ printedName: string;
+ };
+ networks: Record;
+};
+
+function luhnCheckDigit(partial: string): string {
+ let sum = 0;
+ let dbl = true;
+ for (let i = partial.length - 1; i >= 0; i--) {
+ let d = Number(partial[i]);
+ if (dbl) {
+ d *= 2;
+ if (d > 9) d -= 9;
+ }
+ sum += d;
+ dbl = !dbl;
+ }
+ return String((10 - (sum % 10)) % 10);
+}
+
+export function issueOnlineVisaPan(): string {
+ const bin = '400005';
+ const account = String(randomInt(0, 1e9)).padStart(9, '0');
+ const partial = `${bin}${account}`;
+ return partial + luhnCheckDigit(partial);
+}
+
+export function cardToken(pan: string): string {
+ return `tok_${createHash('sha256').update(pan).digest('hex').slice(0, 24)}`;
+}
+
+export function customerRegistryPath(repoRoot: string): string {
+ return path.join(repoRoot, 'config/customers/zardasht-waisi-amin.zbank.v1.json');
+}
+
+export function loadCustomerRegistry(repoRoot: string): Record | null {
+ const p = customerRegistryPath(repoRoot);
+ if (!existsSync(p)) return null;
+ return JSON.parse(readFileSync(p, 'utf8')) as Record;
+}
+
+export function saveCustomerRegistry(repoRoot: string, data: Record): void {
+ const p = customerRegistryPath(repoRoot);
+ mkdirSync(path.dirname(p), { recursive: true });
+ data.asOf = new Date().toISOString();
+ writeFileSync(p, JSON.stringify(data, null, 2), 'utf8');
+}
+
+export function buildOnlineCard(opts: {
+ currency: string;
+ iban: string;
+ accountNo: string | null;
+ nameOnCard: string;
+}): OnlineCard & { pan?: string; cvv?: string } {
+ const pan = issueOnlineVisaPan();
+ const cvv = String(randomInt(100, 999));
+ const expMonth = String(new Date().getMonth() + 1).padStart(2, '0');
+ const expYear = String(new Date().getFullYear() + 4).slice(-2);
+ const id = `zcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`;
+ const nameOnCard = opts.nameOnCard.toUpperCase();
+ return {
+ id,
+ brand: 'Visa',
+ product: 'Z Online Card',
+ formFactor: 'online',
+ currency: opts.currency,
+ linkedIban: opts.iban,
+ linkedAccountNo: opts.accountNo,
+ panMasked: `${pan.slice(0, 6)}******${pan.slice(-4)}`,
+ panToken: cardToken(pan),
+ expiry: `${expMonth}/${expYear}`,
+ nameOnCard,
+ status: 'ACTIVE',
+ spendable: true,
+ onlineOnly: true,
+ atmEnabled: true,
+ contactless: true,
+ createdAt: new Date().toISOString(),
+ pan,
+ cvv,
+ back: {
+ magstripe: true,
+ signatureStrip: true,
+ cvvPanel: true,
+ cvvMasked: '•••',
+ printedName: nameOnCard,
+ },
+ 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' },
+ },
+ };
+}
+
+export function issueCardForCurrency(
+ repoRoot: string,
+ currency: string,
+ revealPan = false,
+): { 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' };
+ const accounts = (reg.accounts as Array>) || [];
+ const acct = accounts.find((a) => String(a.currency).toUpperCase() === currency.toUpperCase());
+ if (!acct) return { ok: false, error: `No IBAN account for ${currency}` };
+
+ const customer = reg.customer as { displayName?: string };
+ const card = buildOnlineCard({
+ currency: String(acct.currency),
+ iban: String(acct.iban),
+ accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
+ nameOnCard: customer.displayName || 'CARDHOLDER',
+ });
+
+ 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;
+ saveCustomerRegistry(repoRoot, reg);
+
+ const out: OnlineCard & { pan?: string; cvv?: string } = { ...publicCard };
+ if (revealPan) {
+ out.pan = pan;
+ out.cvv = cvv;
+ }
+ return { ok: true, card: out, account: acct };
+}