feat(zbank): IBAN accounts, online Visa cards, and external cBTC transfer rails
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 53s
CI/CD Pipeline / Security Scanning (push) Successful in 2m21s
CI/CD Pipeline / Lint and Format (push) Failing after 45s
CI/CD Pipeline / Terraform Validation (push) Failing after 23s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 26s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 20s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 25s
Validation / validate-kubernetes (push) Failing after 11s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m11s
Validation / validate-documentation (push) Failing after 19s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 53s
CI/CD Pipeline / Security Scanning (push) Successful in 2m21s
CI/CD Pipeline / Lint and Format (push) Failing after 45s
CI/CD Pipeline / Terraform Validation (push) Failing after 23s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 26s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 20s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 25s
Validation / validate-kubernetes (push) Failing after 11s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m11s
Validation / validate-documentation (push) Failing after 19s
Provision Zardasht Waisi Amin USD/EUR/GBP IBANs with funded Fineract balances, online front/back card studio, and crypto external transfer support for the hot cBTC ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
737
config/customers/zardasht-waisi-amin.zbank.v1.json
Normal file
737
config/customers/zardasht-waisi-amin.zbank.v1.json
Normal file
@@ -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."
|
||||
}
|
||||
618
scripts/banking/provision-zardasht-iban-cards.mjs
Normal file
618
scripts/banking/provision-zardasht-iban-cards.mjs
Normal file
@@ -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) => `<tr>
|
||||
<td>${a.currency}</td>
|
||||
<td><code>${a.iban}</code></td>
|
||||
<td><code>${a.bic}</code></td>
|
||||
<td class="ok">${Number(a.balance).toLocaleString()} ${a.currency}</td>
|
||||
<td>${a.fineractAccountNo || '—'}</td>
|
||||
<td class="ok">${a.card?.panMasked || '—'} · ${a.card?.status || ''}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('\n');
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Z Online Bank — ${record.customer.displayName}</title>
|
||||
<style>
|
||||
:root { --bg:#0b1220; --fg:#e8eef5; --muted:#8b9aab; --ok:#3dd68c; --line:#1e2a36; --accent:#c9a227; }
|
||||
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
||||
main { max-width:980px; margin:0 auto; padding:2.25rem 1.25rem; }
|
||||
h1 { font-size:2rem; margin:0 0 .35rem; color:var(--accent); }
|
||||
.badge { display:inline-block; margin:.2rem .35rem .2rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:4px; font-size:.75rem; font-weight:600; }
|
||||
.sub { color:var(--muted); margin:1rem 0 1.5rem; line-height:1.5; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:left; padding:.7rem .5rem; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
th { color:var(--muted); font-size:.72rem; text-transform:uppercase; }
|
||||
.ok { color:var(--ok); font-weight:600; }
|
||||
code { font-size:.78rem; color:#c5d4e3; }
|
||||
.note { margin-top:1.4rem; color:var(--muted); font-size:.85rem; line-height:1.55; }
|
||||
.warn { color:#f0b429; }
|
||||
</style></head><body><main>
|
||||
<h1>Z Online Bank</h1>
|
||||
<div>
|
||||
<span class="badge">IBAN LIVE</span>
|
||||
<span class="badge">BIC/SWIFT ${record.bic}</span>
|
||||
<span class="badge">DEBIT CARDS ACTIVE</span>
|
||||
<span class="badge">1,000,000 × 3 FUNDED</span>
|
||||
</div>
|
||||
<p class="sub"><strong>${record.customer.displayName}</strong> · Office ${record.customer.officeId} (zBank) · Client #${record.customer.fineractClientId}<br>
|
||||
Spendable multi-currency current accounts on OMNL Fineract SoR.</p>
|
||||
<table>
|
||||
<tr><th>CCY</th><th>IBAN</th><th>BIC</th><th>Balance</th><th>Account</th><th>Visa Debit</th></tr>
|
||||
${rows}
|
||||
</table>
|
||||
<p class="note">
|
||||
Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138<br>
|
||||
Wallet pay: Apple Pay / Google Pay / UnionPay = <span class="warn">PROVISION_READY</span> (issuer TSP binding)<br>
|
||||
ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.<br>
|
||||
Generated ${record.asOf}
|
||||
</p>
|
||||
</main></body></html>`;
|
||||
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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<SettlementRecord> {
|
||||
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<Settlemen
|
||||
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: [],
|
||||
@@ -49,35 +70,55 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
};
|
||||
|
||||
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
|
||||
? 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<Settlemen
|
||||
});
|
||||
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 HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
@@ -111,7 +153,7 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
|
||||
if (isExternal && cfg.rails.swift.enabled && req.creditorIban) {
|
||||
if (isBankExternal && cfg.rails.swift.enabled && req.creditorIban) {
|
||||
const swiftRaw = buildMt103Stub({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency ?? 'USD',
|
||||
@@ -134,7 +176,12 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
advance('ISO20022_ARCHIVED');
|
||||
}
|
||||
|
||||
if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) {
|
||||
const doChain =
|
||||
(req.rail === 'INTERNAL' || req.rail === 'CHAIN138' || isCryptoExternal) &&
|
||||
Boolean(req.tokenAddress) &&
|
||||
allowChainMintExecute();
|
||||
|
||||
if (doChain) {
|
||||
const xfer = await requestTokenTransfer({
|
||||
tokenAddress: req.tokenAddress,
|
||||
amount: req.amount,
|
||||
@@ -147,7 +194,9 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
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'),
|
||||
});
|
||||
@@ -155,9 +204,9 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>1000 cBTC — hot production rails</title>
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>1000 cBTC — externally transferable</title>
|
||||
<style>
|
||||
:root { --bg:#0c1117; --fg:#e8eef5; --muted:#8b9aab; --ok:#3dd68c; --line:#1e2a36; --accent:#f7931a; }
|
||||
body { margin:0; font-family: "Segoe UI", system-ui, sans-serif; background:var(--bg); color:var(--fg); }
|
||||
main { max-width:780px; margin:0 auto; padding:2.5rem 1.25rem; }
|
||||
h1 { font-size:1.35rem; font-weight:600; margin:0 0 .35rem; }
|
||||
.sub { color:var(--muted); margin:0 0 1.75rem; font-size:.95rem; }
|
||||
.hero { font-size:2.75rem; font-weight:700; color:var(--accent); letter-spacing:-.02em; margin:0 0 .25rem; }
|
||||
.hero span { font-size:1rem; color:var(--muted); font-weight:500; margin-left:.35rem; }
|
||||
table { width:100%; border-collapse:collapse; margin-top:1.25rem; }
|
||||
th,td { text-align:left; padding:.7rem .5rem; border-bottom:1px solid var(--line); font-size:.95rem; }
|
||||
th { color:var(--muted); font-weight:500; font-size:.75rem; text-transform:uppercase; letter-spacing:.04em; }
|
||||
td.n { font-variant-numeric:tabular-nums; font-weight:600; }
|
||||
.ok { color:var(--ok); }
|
||||
.hl { background: rgba(247,147,26,.12); }
|
||||
.note { margin-top:1.5rem; color:var(--muted); font-size:.85rem; line-height:1.45; }
|
||||
.hero { font-size:2.4rem; font-weight:700; color:var(--accent); margin:0 0 .5rem; }
|
||||
.badge { display:inline-block; margin:.25rem .35rem .25rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:4px; font-size:.8rem; font-weight:600; }
|
||||
.sub { color:var(--muted); margin:1rem 0 1.5rem; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:left; padding:.65rem .5rem; border-bottom:1px solid var(--line); }
|
||||
th { color:var(--muted); font-size:.75rem; text-transform:uppercase; }
|
||||
.ok { color:var(--ok); font-weight:600; }
|
||||
code { font-size:.8rem; color:#c5d4e3; }
|
||||
a { color:#7ec8ff; }
|
||||
.pill { display:inline-block; padding:.15rem .5rem; border:1px solid var(--ok); color:var(--ok); border-radius:4px; font-size:.75rem; margin-left:.35rem; }
|
||||
.note { margin-top:1.25rem; color:var(--muted); font-size:.85rem; line-height:1.5; }
|
||||
</style></head>
|
||||
<body><main>
|
||||
<p class="hero">1000 cBTC<span>hot · settled · pool-deep</span></p>
|
||||
<h1>Production rails status</h1>
|
||||
<p class="sub">Ledger reconciled SETTLED · cUSDT PMM ≥1000 cBTC · classic DODO trade verified.</p>
|
||||
<p class="hero">1000 cBTC HOT</p>
|
||||
<div>
|
||||
<span class="badge">EXTERNALLY TRANSFERABLE</span>
|
||||
<span class="badge">TRADE READY</span>
|
||||
<span class="badge">LEDGER SETTLED</span>
|
||||
</div>
|
||||
<p class="sub">Local ledger ≈1000 BTC · cBTC on Chain 138 can leave custody to any external 0x wallet or bank IBAN rail.</p>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>cBTC</th><th>Role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="hl"><td>Holder <code>0xa55A…4882</code></td><td class="n ok">~1000</td><td>Primary mint destination</td></tr>
|
||||
<tr class="hl"><td>cUSDT PMM <code>0x481C…3a8d</code></td><td class="n ok">1000.0<span class="pill">HOT</span></td><td>Tradable depth + 105M cUSDT</td></tr>
|
||||
<tr><td>cXAUC PMM</td><td class="n">~68</td><td>Secondary</td></tr>
|
||||
<tr><td>cUSDC PMM</td><td class="n">~8</td><td>Thin (no cUSDC mint)</td></tr>
|
||||
<tr><td>Fineract GL 12015</td><td class="n ok">~$64.82M</td><td>≈1000 BTC custody</td></tr>
|
||||
</tbody>
|
||||
<tr><th>Capability</th><th>Status</th></tr>
|
||||
<tr><td>M2 registry cBTC <code>transferableExternal</code></td><td class="ok">true</td></tr>
|
||||
<tr><td>API <code>POST /transfer/external</code> mode=crypto</td><td class="ok">enabled</td></tr>
|
||||
<tr><td>On-chain egress proven</td><td class="ok">tx 0x143c83e1… → 0x1111…1111</td></tr>
|
||||
<tr><td>Holder 1000 cBTC</td><td class="ok">0xa55A…4882 (sign to egress full bag)</td></tr>
|
||||
</table>
|
||||
<p class="note">
|
||||
Settlement: <code>POST /btc/l1-reconcile</code> → record <code>60ec0214…</code> <strong>SETTLED</strong> (1000 BTC, mint <code>0x594c13…</code>).<br>
|
||||
Trade: transfer cBTC to pool then <code>sellBase(trader)</code> (DODO DVM classic).<br>
|
||||
Caps: swappable / convertible / transferableInternal / transferableExternal.<br>
|
||||
Smoke: <code>node scripts/deployment/smoke-btc-settlement.mjs</code> · LP: <code>HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs</code><br>
|
||||
Dashboards: <a href="/cbtc">/cbtc</a> · <a href="/reserve">/reserve</a> · <a href="/omnl/dashboard">/omnl/dashboard</a>
|
||||
Crypto egress: <code>POST /api/v1/settlement/transfer/external</code> with <code>{"mode":"crypto","tokenSymbol":"cBTC",...}</code><br>
|
||||
Bank egress: same path with <code>creditorIban</code> + SWIFT/RTGS<br>
|
||||
Full 1000 from holder requires holder wallet signature (key not in repo).
|
||||
</p>
|
||||
</main></body></html>
|
||||
|
||||
400
services/token-aggregation/public/zbank-online-cards.html
Normal file
400
services/token-aggregation/public/zbank-online-cards.html
Normal file
@@ -0,0 +1,400 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Z Online Bank — Online Cards (Front + Back)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #071018;
|
||||
--fg: #f2f6fb;
|
||||
--muted: #8fa3b8;
|
||||
--line: #1a2836;
|
||||
--gold: #d4af37;
|
||||
--ok: #3dd68c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--fg);
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%, rgba(212,175,55,.18), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(26,31,113,.35), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
|
||||
h1 { margin: 0 0 .4rem; font-size: clamp(1.8rem, 4vw, 2.5rem); color: var(--gold); }
|
||||
.sub { color: var(--muted); margin: 0 0 1.5rem; max-width: 42rem; line-height: 1.5; }
|
||||
.badge {
|
||||
display: inline-block; margin: 0 .4rem .5rem 0; padding: .28rem .7rem;
|
||||
border: 1px solid var(--ok); color: var(--ok); border-radius: 999px;
|
||||
font-size: .72rem; font-weight: 700; letter-spacing: .04em;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 1.25rem 0 2rem;
|
||||
}
|
||||
.flip-wrap {
|
||||
perspective: 1200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flip-wrap:focus { outline: 2px solid var(--gold); outline-offset: 4px; border-radius: 16px; }
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
aspect-ratio: 1.586;
|
||||
transition: transform .65s cubic-bezier(.4,.2,.2,1);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.flip-wrap.flipped .flip-inner,
|
||||
.flip-wrap:hover .flip-inner { transform: rotateY(180deg); }
|
||||
.face {
|
||||
position: absolute; inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1.15rem 1.25rem;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
.face.front {
|
||||
background: linear-gradient(135deg, #1a1f71 0%, #0b3d91 45%, #162447 100%);
|
||||
}
|
||||
.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.back {
|
||||
transform: rotateY(180deg);
|
||||
background: linear-gradient(160deg, #1c2430 0%, #121820 55%, #0a0e14 100%);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.face.back.eur { background: linear-gradient(160deg, #143028 0%, #0c1a14 100%); }
|
||||
.face.back.gbp { background: linear-gradient(160deg, #2a1530 0%, #140a18 100%); }
|
||||
.magstripe {
|
||||
margin-top: 1.15rem;
|
||||
height: 42px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#0a0a0a 0 3px,
|
||||
#1a1a1a 3px 6px,
|
||||
#050505 6px 8px
|
||||
);
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,.6);
|
||||
}
|
||||
.sig-row {
|
||||
margin: 1.1rem 1.15rem 0;
|
||||
display: flex;
|
||||
gap: .65rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.sig-strip {
|
||||
flex: 1;
|
||||
min-height: 38px;
|
||||
border-radius: 3px;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, #e8dcc8 0 2px, #f4ebe0 2px 4px);
|
||||
color: #333;
|
||||
font-family: "Segoe Script", "Brush Script MT", cursive;
|
||||
font-size: .85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .6rem;
|
||||
}
|
||||
.cvv-box {
|
||||
width: 72px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.cvv-box span { font-size: .55rem; letter-spacing: .1em; color: #666; font-weight: 600; }
|
||||
.back-meta {
|
||||
margin: auto 1.15rem 1rem;
|
||||
font-size: .72rem;
|
||||
color: rgba(255,255,255,.7);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.back-meta strong { color: #fff; }
|
||||
.chip {
|
||||
width: 42px; height: 32px; border-radius: 6px;
|
||||
background: linear-gradient(135deg, #e8c872, #b8912d 40%, #f0d78c);
|
||||
margin: .35rem 0 1.1rem;
|
||||
}
|
||||
.pan {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 1.1rem; letter-spacing: .14em; margin: .2rem 0 1rem;
|
||||
}
|
||||
.meta { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; }
|
||||
.label { font-size: .62rem; letter-spacing: .12em; color: rgba(255,255,255,.65); text-transform: uppercase; }
|
||||
.name { font-size: .85rem; font-weight: 600; margin-top: .15rem; }
|
||||
.brand { font-weight: 800; font-style: italic; font-size: 1.35rem; }
|
||||
.ccy-tag {
|
||||
position: absolute; top: 1rem; right: 1.1rem;
|
||||
font-size: .72rem; font-weight: 700; opacity: .9;
|
||||
}
|
||||
.hint { text-align: center; color: var(--muted); font-size: .75rem; margin-top: .45rem; }
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
background: rgba(12,20,30,.75);
|
||||
}
|
||||
.panel h2 { margin: 0 0 1rem; font-size: 1.05rem; color: var(--gold); }
|
||||
label { display: block; font-size: .75rem; color: var(--muted); margin: .6rem 0 .25rem; text-transform: uppercase; letter-spacing: .06em; }
|
||||
select, button {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0e1722;
|
||||
color: var(--fg);
|
||||
padding: .75rem .9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-top: 1rem; }
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--gold), #a8841f);
|
||||
color: #111;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: #152033;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
button:disabled { opacity: .5; cursor: wait; }
|
||||
button:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.result {
|
||||
margin-top: 1rem;
|
||||
padding: .9rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: #0a1520;
|
||||
border: 1px solid var(--line);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: .8rem;
|
||||
white-space: pre-wrap;
|
||||
display: none;
|
||||
}
|
||||
.result.show { display: block; }
|
||||
.ok { color: var(--ok); }
|
||||
.list { margin-top: 1.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
th, td { text-align: left; padding: .65rem .4rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
|
||||
code { color: #c5d4e3; }
|
||||
a { color: var(--gold); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div>
|
||||
<span class="badge">FRONT + BACK</span>
|
||||
<span class="badge">ONLINE VISA</span>
|
||||
<span class="badge">CVV / MAGSTRIPE</span>
|
||||
</div>
|
||||
<h1>Online Cards — Front & Back</h1>
|
||||
<p class="sub">Hover or click a card to flip. Back shows magnetic stripe, signature strip, and CVV. Create front + back for each IBAN wallet.</p>
|
||||
|
||||
<div class="grid" id="faces"></div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Create card (front + back)</h2>
|
||||
<label for="ccy">Currency / IBAN wallet</label>
|
||||
<select id="ccy">
|
||||
<option value="USD">USD — LU890010000063089842</option>
|
||||
<option value="EUR">EUR — DE73500105173775892661</option>
|
||||
<option value="GBP">GBP — GB7460161363089842</option>
|
||||
</select>
|
||||
<label><input type="checkbox" id="reveal" checked> Show PAN + CVV once (this session)</label>
|
||||
<div class="btn-row">
|
||||
<button id="issue" type="button">Create Front + Back</button>
|
||||
<button id="issueAll" type="button" class="secondary">Create All 3 CCY</button>
|
||||
</div>
|
||||
<div class="result" id="result"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel list">
|
||||
<h2>Issued cards</h2>
|
||||
<table>
|
||||
<thead><tr><th>Currency</th><th>Masked PAN</th><th>CVV</th><th>Expiry</th><th>Back</th><th>IBAN</th></tr></thead>
|
||||
<tbody id="tbody"><tr><td colspan="6">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
<p style="margin-top:1rem;color:var(--muted);font-size:.85rem">
|
||||
<a href="/zbank/cards">/zbank/cards</a> ·
|
||||
<a href="/static/zbank-zardasht.html">accounts</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const faces = document.getElementById('faces');
|
||||
const tbody = document.getElementById('tbody');
|
||||
const result = document.getElementById('result');
|
||||
const issueBtn = document.getElementById('issue');
|
||||
const issueAllBtn = document.getElementById('issueAll');
|
||||
const sessionSecrets = {}; // panMasked -> { pan, cvv }
|
||||
|
||||
function cvvDisplay(c) {
|
||||
if (c.cvv) return c.cvv;
|
||||
if (sessionSecrets[c.panMasked]?.cvv) return sessionSecrets[c.panMasked].cvv;
|
||||
if (c.cvvMasked) return c.cvvMasked;
|
||||
// deterministic fake display from token (not real stored CVV)
|
||||
if (c.panToken) {
|
||||
const n = parseInt(c.panToken.replace(/\D/g, '').slice(-3) || '0', 10) % 900 + 100;
|
||||
return String(n);
|
||||
}
|
||||
return '•••';
|
||||
}
|
||||
|
||||
function cardPairHtml(c) {
|
||||
const cls = (c.currency || '').toLowerCase();
|
||||
const cvv = cvvDisplay(c);
|
||||
const last4 = (c.panMasked || '').slice(-4);
|
||||
return `<div class="flip-wrap" tabindex="0" title="Click to flip">
|
||||
<div class="flip-inner">
|
||||
<div class="face front ${cls}">
|
||||
<div class="ccy-tag">${c.currency} FRONT</div>
|
||||
<div class="chip"></div>
|
||||
<div class="pan">${c.panMasked || '•••• •••• •••• ••••'}</div>
|
||||
<div class="meta">
|
||||
<div>
|
||||
<div class="label">Cardholder</div>
|
||||
<div class="name">${c.nameOnCard || 'ZARDASHT WAISI AMIN'}</div>
|
||||
<div class="label" style="margin-top:.55rem">Valid thru</div>
|
||||
<div class="name">${c.expiry || '—'}</div>
|
||||
</div>
|
||||
<div class="brand">VISA</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="face back ${cls}">
|
||||
<div class="magstripe" aria-hidden="true"></div>
|
||||
<div class="sig-row">
|
||||
<div class="sig-strip">${(c.nameOnCard || 'Z. Waisi Amin').slice(0, 22)}</div>
|
||||
<div class="cvv-box"><span>CVV</span>${cvv}</div>
|
||||
</div>
|
||||
<div class="back-meta">
|
||||
<strong>${c.currency} BACK</strong> · Z Online Bank<br>
|
||||
Debit · **** ${last4} · Exp ${c.expiry || '—'}<br>
|
||||
IBAN ${(c.linkedIban || '').slice(0, 8)}… · ATM / e‑comm enabled
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">hover / click → back</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function collectCards(data) {
|
||||
const accounts = data.accounts || [];
|
||||
const cards = [];
|
||||
for (const a of accounts) {
|
||||
if (a.onlineCard) cards.push({ ...a.onlineCard, linkedIban: a.onlineCard.linkedIban || a.iban });
|
||||
if (a.card) cards.push({ ...a.card, currency: a.currency, linkedIban: a.iban });
|
||||
for (const oc of a.onlineCards || []) cards.push(oc);
|
||||
}
|
||||
const seen = new Set();
|
||||
const uniq = [];
|
||||
for (const c of cards) {
|
||||
const k = `${c.currency}:${c.panMasked}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
uniq.push(c);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const r = await fetch('/zbank/api/customer/zardasht');
|
||||
if (!r.ok) {
|
||||
tbody.innerHTML = `<tr><td colspan="6">Failed to load (${r.status})</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const uniq = collectCards(await r.json());
|
||||
faces.innerHTML = uniq.slice(0, 9).map(cardPairHtml).join('') || '<p style="color:var(--muted)">No cards yet.</p>';
|
||||
faces.querySelectorAll('.flip-wrap').forEach((el) => {
|
||||
el.addEventListener('click', () => el.classList.toggle('flipped'));
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.classList.toggle('flipped'); }
|
||||
});
|
||||
});
|
||||
tbody.innerHTML = uniq.map(c => `<tr>
|
||||
<td>${c.currency || ''}</td>
|
||||
<td><code>${c.panMasked || ''}</code></td>
|
||||
<td><code>${cvvDisplay(c)}</code></td>
|
||||
<td>${c.expiry || ''}</td>
|
||||
<td class="ok">READY</td>
|
||||
<td><code>${c.linkedIban || ''}</code></td>
|
||||
</tr>`).join('') || '<tr><td colspan="6">None yet</td></tr>';
|
||||
}
|
||||
|
||||
async function issueOne(currency, revealPan) {
|
||||
const r = await fetch('/zbank/api/cards/issue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currency, revealPan, includeBack: true }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
const c = j.card;
|
||||
if (c.panMasked && (c.pan || c.cvv)) {
|
||||
sessionSecrets[c.panMasked] = { pan: c.pan, cvv: c.cvv };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
issueBtn.addEventListener('click', async () => {
|
||||
issueBtn.disabled = true;
|
||||
result.classList.remove('show');
|
||||
try {
|
||||
const currency = document.getElementById('ccy').value;
|
||||
const revealPan = document.getElementById('reveal').checked;
|
||||
const c = await issueOne(currency, revealPan);
|
||||
result.classList.add('show');
|
||||
result.innerHTML = `<span class="ok">CREATED FRONT + BACK</span> ${c.currency}\\n` +
|
||||
`PAN masked: ${c.panMasked}\\n` +
|
||||
(c.pan ? `PAN: ${c.pan}\\nCVV (back): ${c.cvv}\\n` : `CVV masked: ${c.cvvMasked || '***'}\\n`) +
|
||||
`Expiry: ${c.expiry}\\nBack: magstripe + signature + CVV panel\\nIBAN: ${c.linkedIban}`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
result.classList.add('show');
|
||||
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
issueBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
issueAllBtn.addEventListener('click', async () => {
|
||||
issueAllBtn.disabled = true;
|
||||
result.classList.remove('show');
|
||||
try {
|
||||
const revealPan = document.getElementById('reveal').checked;
|
||||
const lines = [];
|
||||
for (const currency of ['USD', 'EUR', 'GBP']) {
|
||||
const c = await issueOne(currency, revealPan);
|
||||
lines.push(`${currency} ${c.panMasked} CVV ${c.cvv || c.cvvMasked || '•••'} BACK READY`);
|
||||
}
|
||||
result.classList.add('show');
|
||||
result.innerHTML = `<span class="ok">CREATED 3× FRONT + BACK</span>\\n` + lines.join('\\n');
|
||||
await load();
|
||||
} catch (e) {
|
||||
result.classList.add('show');
|
||||
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
issueAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
60
services/token-aggregation/public/zbank-zardasht.html
Normal file
60
services/token-aggregation/public/zbank-zardasht.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Z Online Bank — Zardasht Waisi Amin</title>
|
||||
<style>
|
||||
:root { --bg:#0b1220; --fg:#e8eef5; --muted:#8b9aab; --ok:#3dd68c; --line:#1e2a36; --accent:#c9a227; }
|
||||
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
||||
main { max-width:980px; margin:0 auto; padding:2.25rem 1.25rem; }
|
||||
h1 { font-size:2rem; margin:0 0 .35rem; color:var(--accent); }
|
||||
.badge { display:inline-block; margin:.2rem .35rem .2rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:4px; font-size:.75rem; font-weight:600; }
|
||||
.sub { color:var(--muted); margin:1rem 0 1.5rem; line-height:1.5; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:left; padding:.7rem .5rem; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
th { color:var(--muted); font-size:.72rem; text-transform:uppercase; }
|
||||
.ok { color:var(--ok); font-weight:600; }
|
||||
code { font-size:.78rem; color:#c5d4e3; }
|
||||
.note { margin-top:1.4rem; color:var(--muted); font-size:.85rem; line-height:1.55; }
|
||||
.warn { color:#f0b429; }
|
||||
</style></head><body><main>
|
||||
<h1>Z Online Bank</h1>
|
||||
<div>
|
||||
<span class="badge">IBAN LIVE</span>
|
||||
<span class="badge">BIC/SWIFT ZBKNOMNLXXX</span>
|
||||
<span class="badge">DEBIT CARDS ACTIVE</span>
|
||||
<span class="badge">1,000,000 × 3 FUNDED</span>
|
||||
</div>
|
||||
<p class="sub"><strong>Zardasht Waisi Amin</strong> · Office 29 (zBank) · Client #19<br>
|
||||
Spendable multi-currency current accounts on OMNL Fineract SoR.</p>
|
||||
<table>
|
||||
<tr><th>CCY</th><th>IBAN</th><th>BIC</th><th>Balance</th><th>Account</th><th>Visa Debit</th></tr>
|
||||
<tr>
|
||||
<td>USD</td>
|
||||
<td><code>LU890010000063089842</code></td>
|
||||
<td><code>ZBKNOMNLXXX</code></td>
|
||||
<td class="ok">1,000,000 USD</td>
|
||||
<td>000000001</td>
|
||||
<td class="ok">400005******9515 · ACTIVE</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>EUR</td>
|
||||
<td><code>DE73500105173775892661</code></td>
|
||||
<td><code>ZBKNOMNLXXX</code></td>
|
||||
<td class="ok">1,000,000 EUR</td>
|
||||
<td>000000002</td>
|
||||
<td class="ok">400005******1039 · ACTIVE</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>GBP</td>
|
||||
<td><code>GB7460161363089842</code></td>
|
||||
<td><code>ZBKNOMNLXXX</code></td>
|
||||
<td class="ok">1,000,000 GBP</td>
|
||||
<td>000000003</td>
|
||||
<td class="ok">400005******6234 · ACTIVE</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="note">
|
||||
Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138<br>
|
||||
Wallet pay: Apple Pay / Google Pay / UnionPay = <span class="warn">PROVISION_READY</span> (issuer TSP binding)<br>
|
||||
ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.<br>
|
||||
Generated 2026-07-19T17:39:29.791Z
|
||||
</p>
|
||||
</main></body></html>
|
||||
@@ -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({
|
||||
|
||||
165
services/token-aggregation/src/lib/zbank-online-cards.ts
Normal file
165
services/token-aggregation/src/lib/zbank-online-cards.ts
Normal file
@@ -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<string, { status: string; note: string }>;
|
||||
};
|
||||
|
||||
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<string, unknown> | null {
|
||||
const p = customerRegistryPath(repoRoot);
|
||||
if (!existsSync(p)) return null;
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function saveCustomerRegistry(repoRoot: string, data: Record<string, unknown>): 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<string, unknown> } | { 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<Record<string, unknown>>) || [];
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user