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

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:
2026-07-19 11:19:24 -07:00
parent 5a9889cace
commit 9ea42d4df6
11 changed files with 2295 additions and 92 deletions

View 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."
}

View 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);
});

View File

@@ -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) {

View File

@@ -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'),
});
}

View File

@@ -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) });

View File

@@ -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'),
});
}

View File

@@ -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>

View 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 &amp; 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 / ecomm 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>

View 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>

View File

@@ -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({

View 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 };
}