diff --git a/config/customers/zardasht-waisi-amin.zbank.v1.json b/config/customers/zardasht-waisi-amin.zbank.v1.json new file mode 100644 index 0000000..04465ce --- /dev/null +++ b/config/customers/zardasht-waisi-amin.zbank.v1.json @@ -0,0 +1,737 @@ +{ + "version": "1.0.0", + "asOf": "2026-07-19T17:55:35.143Z", + "bank": { + "name": "Z Online Bank", + "officeId": 29, + "externalId": "ZBANK-OMNL", + "bic": "ZBKNOMNLXXX", + "swiftMemberStub": true + }, + "customer": { + "firstname": "Zardasht", + "lastname": "Waisi Amin", + "displayName": "Zardasht Waisi Amin", + "externalId": "ZBANK-CUST-ZARDASHT-WAISI-AMIN", + "email": "zardasht.waisi.amin@omdnl.org", + "mobile": "+9647700000001", + "officeId": 29, + "fineractClientId": 19, + "fineractAccountNo": "000000019" + }, + "bic": "ZBKNOMNLXXX", + "accounts": [ + { + "currency": "USD", + "iban": "LU890010000063089842", + "bic": "ZBKNOMNLXXX", + "swift": "ZBKNOMNLXXX", + "balance": 1000000, + "fineractAccountNo": "000000001", + "fineractSavingsId": 1, + "fineractStatus": "Active", + "spendable": true, + "card": { + "brand": "Visa", + "product": "OMNL Z Online Debit", + "formFactor": "virtual", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******9515", + "panToken": "tok_a2708a298be420b28f91dbb8", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "atmEnabled": true, + "contactless": true, + "networks": { + "visa": { + "status": "ISSUED_LEDGER", + "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Apple Pay Payee / issuer TSP" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Google Pay OPC / issuer TSP" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Pending UnionPay dual-brand / issuer agreement" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM cash-out via correspondent/ATM switch when BIN live" + } + } + }, + "onlineCards": [ + { + "id": "zcard_mrs34bz1_9046", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******6727", + "panToken": "tok_a997672e5545f2fe55baaf91", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:43:47.102Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + }, + { + "id": "zcard_mrs35wwu_9534", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******0431", + "panToken": "tok_09a1e1c66c6d3d514c322c78", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:45:00.894Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + }, + { + "id": "zcard_mrs36bc8_7413", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******5692", + "panToken": "tok_512a2450c63740fee6956fb8", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:45:19.592Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + }, + { + "id": "zcard_mrs3ji8l_6109", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******8371", + "panToken": "tok_8f114918cde825a53e76a911", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.063Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + ], + "onlineCard": { + "id": "zcard_mrs3ji8l_6109", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "USD", + "linkedIban": "LU890010000063089842", + "linkedAccountNo": "000000001", + "panMasked": "400005******8371", + "panToken": "tok_8f114918cde825a53e76a911", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.063Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + }, + { + "currency": "EUR", + "iban": "DE73500105173775892661", + "bic": "ZBKNOMNLXXX", + "swift": "ZBKNOMNLXXX", + "balance": 1000000, + "fineractAccountNo": "000000002", + "fineractSavingsId": 2, + "fineractStatus": "Active", + "spendable": true, + "card": { + "brand": "Visa", + "product": "OMNL Z Online Debit", + "formFactor": "virtual", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******1039", + "panToken": "tok_663ab4f2ea2e88a6b114a0fc", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "atmEnabled": true, + "contactless": true, + "networks": { + "visa": { + "status": "ISSUED_LEDGER", + "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Apple Pay Payee / issuer TSP" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Google Pay OPC / issuer TSP" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Pending UnionPay dual-brand / issuer agreement" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM cash-out via correspondent/ATM switch when BIN live" + } + } + }, + "onlineCards": [ + { + "id": "zcard_mrs34bz6_1976", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******8584", + "panToken": "tok_b0c06cdab692c26fe5c87701", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:43:47.106Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + }, + { + "id": "zcard_mrs3jiao_7411", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******8745", + "panToken": "tok_945a6c603e918c6f8c20ff17", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.136Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + ], + "onlineCard": { + "id": "zcard_mrs3jiao_7411", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "EUR", + "linkedIban": "DE73500105173775892661", + "linkedAccountNo": "000000002", + "panMasked": "400005******8745", + "panToken": "tok_945a6c603e918c6f8c20ff17", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.136Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + }, + { + "currency": "GBP", + "iban": "GB7460161363089842", + "bic": "ZBKNOMNLXXX", + "swift": "ZBKNOMNLXXX", + "balance": 1000000, + "fineractAccountNo": "000000003", + "fineractSavingsId": 3, + "fineractStatus": "Active", + "spendable": true, + "card": { + "brand": "Visa", + "product": "OMNL Z Online Debit", + "formFactor": "virtual", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******6234", + "panToken": "tok_fab1d1a25cbc632859aff1f3", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "atmEnabled": true, + "contactless": true, + "networks": { + "visa": { + "status": "ISSUED_LEDGER", + "note": "Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Apple Pay Payee / issuer TSP" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Tokenization pending Google Pay OPC / issuer TSP" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Pending UnionPay dual-brand / issuer agreement" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU send/receive via SWIFT MT103 stub + HYBX remittance adapter" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM cash-out via correspondent/ATM switch when BIN live" + } + } + }, + "onlineCards": [ + { + "id": "zcard_mrs34bz6_6552", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******2306", + "panToken": "tok_0c315e1b57f1bc3aebaa8573", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:43:47.107Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + }, + { + "id": "zcard_mrs3jiav_8336", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******7277", + "panToken": "tok_e1705671689aa4f0e43ff0ec", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.143Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + ], + "onlineCard": { + "id": "zcard_mrs3jiav_8336", + "brand": "Visa", + "product": "Z Online Card", + "formFactor": "online", + "currency": "GBP", + "linkedIban": "GB7460161363089842", + "linkedAccountNo": "000000003", + "panMasked": "400005******7277", + "panToken": "tok_e1705671689aa4f0e43ff0ec", + "expiry": "07/30", + "nameOnCard": "ZARDASHT WAISI AMIN", + "status": "ACTIVE", + "spendable": true, + "onlineOnly": true, + "atmEnabled": true, + "contactless": true, + "createdAt": "2026-07-19T17:55:35.143Z", + "networks": { + "visa": { + "status": "ISSUED_ONLINE", + "note": "Online virtual Visa — OMNL BIN 400005" + }, + "applePay": { + "status": "PROVISION_READY", + "note": "Add to Apple Wallet when TSP live" + }, + "googlePay": { + "status": "PROVISION_READY", + "note": "Add to Google Wallet when TSP live" + }, + "unionPay": { + "status": "PROVISION_READY", + "note": "Dual-brand pending" + }, + "westernUnion": { + "status": "RAIL_MAPPED", + "note": "WU via SWIFT/HYBX" + }, + "atm": { + "status": "ENABLED_LEDGER", + "note": "ATM when BIN switch live" + }, + "ecommerce": { + "status": "ACTIVE", + "note": "Online CNP spend on linked IBAN balance" + } + } + } + } + ], + "walletPay": { + "applePay": "PROVISION_READY", + "googlePay": "PROVISION_READY", + "unionPay": "PROVISION_READY", + "westernUnion": "RAIL_MAPPED", + "atmWorldwide": "ENABLED_LEDGER" + }, + "disclaimer": "Virtual Visa debit cards use OMNL BIN 400005 and are spendable on OMNL ledger rails. Live Visa/Mastercard scheme, Apple Pay, Google Pay, UnionPay, ATM consortia, and Western Union agent cash-out require licensed BIN sponsorship and network certification." +} \ No newline at end of file diff --git a/scripts/banking/provision-zardasht-iban-cards.mjs b/scripts/banking/provision-zardasht-iban-cards.mjs new file mode 100644 index 0000000..4f8a3aa --- /dev/null +++ b/scripts/banking/provision-zardasht-iban-cards.mjs @@ -0,0 +1,618 @@ +#!/usr/bin/env node +/** + * Provision Z Online Bank (zBank / Office 29) multi-currency IBAN accounts + * + virtual Visa debit cards for a named customer. + * + * Ledger/SoR: Apache Fineract (savings accounts + deposits). + * Cards: OMNL-issued virtual debit instruments linked to each IBAN. + * Scheme networks (Visa DPS / Apple Pay / Google Pay / UnionPay / WU / ATM): + * recorded as ready-for-issuer-binding — require BIN sponsorship / BaaS. + * + * Usage: + * node scripts/banking/provision-zardasht-iban-cards.mjs + */ +import { createHash, randomBytes, randomInt } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '../..'); +const OUT_DIR = join(ROOT, 'config/customers'); +const OUT_FILE = join(OUT_DIR, 'zardasht-waisi-amin.zbank.v1.json'); +const DASH_FILE = join(ROOT, 'services/token-aggregation/public/zbank-zardasht.html'); + +const CUSTOMER = { + firstname: 'Zardasht', + lastname: 'Waisi Amin', + displayName: 'Zardasht Waisi Amin', + externalId: 'ZBANK-CUST-ZARDASHT-WAISI-AMIN', + email: 'zardasht.waisi.amin@omdnl.org', + mobile: '+9647700000001', + officeId: Number(process.env.ZBANK_OFFICE_ID || 29), +}; + +const FUND_AMOUNT = Number(process.env.ZBANK_FUND_AMOUNT || 1_000_000); +const BIC = process.env.ZBANK_BIC || 'ZBKNOMNLXXX'; // zBank OMNL +const BANK_CODE_GB = '601613'; // sort-code style BBAN bank id +const BANK_CODE_DE = '50010517'; // BLZ-style for EUR IBAN +const BANK_CODE_LU = '001'; // LU bank code for USD multi-ccy book + +function loadEnvFile(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) { + const m = line.match(/^\s*([^#=]+)=(.*)$/); + if (!m) continue; + const k = m[1].trim(); + const v = m[2].trim().replace(/^["']|["']$/g, ''); + if (!(k in process.env) || process.env[k] === '') process.env[k] = v; + } +} +loadEnvFile(join(ROOT, '.env')); + +function fineractBase() { + return (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); +} +function fineractHeaders() { + const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; + const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'ali_hospitallers_tenant'; + const pass = process.env.OMNL_FINERACT_PASSWORD || ''; + if (!fineractBase() || !pass) throw new Error('Fineract credentials required'); + return { + Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`, + 'Fineract-Platform-TenantId': tenant, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; +} + +async function fineract(method, path, body) { + const url = `${fineractBase()}${path}`; + const res = await fetch(url, { + method, + headers: fineractHeaders(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text }; + } + if (!res.ok) { + const msg = json?.defaultUserMessage || json?.message || text.slice(0, 400); + const err = new Error(`${method} ${path} → ${res.status}: ${msg}`); + err.status = res.status; + err.body = json; + throw err; + } + return json; +} + +/** ISO 13616 mod-97 IBAN check digits */ +function ibanCheckDigits(country, bban) { + const rearranged = `${bban}${country}00`.toUpperCase(); + const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55)); + let rem = 0n; + for (const ch of numeric) rem = (rem * 10n + BigInt(ch)) % 97n; + const check = Number(98n - rem); + return String(check).padStart(2, '0'); +} + +function buildIban(country, bban) { + const clean = bban.replace(/\s/g, '').toUpperCase(); + const cd = ibanCheckDigits(country, clean); + return `${country}${cd}${clean}`; +} + +function isIbanValid(iban) { + const n = iban.replace(/\s/g, '').toUpperCase(); + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(n)) return false; + const rearranged = n.slice(4) + n.slice(0, 4); + const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55)); + let rem = 0; + for (const ch of numeric) rem = (rem * 10 + parseInt(ch, 10)) % 97; + return rem === 1; +} + +function luhnCheckDigit(partial) { + let sum = 0; + let dbl = true; + for (let i = partial.length - 1; i >= 0; i--) { + let d = Number(partial[i]); + if (dbl) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + dbl = !dbl; + } + return String((10 - (sum % 10)) % 10); +} + +/** OMNL private BIN 400005 (Visa-format Luhn) — not a scheme-sponsored production BIN */ +function issueVirtualVisaPan() { + const bin = '400005'; + const account = String(randomInt(0, 1e9)).padStart(9, '0'); + const partial = `${bin}${account}`; + return partial + luhnCheckDigit(partial); +} + +function cardToken(pan) { + return `tok_${createHash('sha256').update(pan).digest('hex').slice(0, 24)}`; +} + +function todayYmd() { + return new Date().toISOString().slice(0, 10); +} + +async function ensureGbpCurrency() { + try { + const cur = await fineract('GET', '/currencies'); + const codes = (cur.selectedCurrencyOptions || []).map((c) => c.code); + if (codes.includes('GBP')) return true; + // Fineract PUT currencies replaces selected set — merge carefully + const selected = [...new Set([...codes, 'GBP'])]; + await fineract('PUT', '/currencies', { currencies: selected }); + console.log('Added GBP to Fineract selected currencies'); + return true; + } catch (e) { + console.warn('GBP currency not available:', e.message); + return false; + } +} + +async function findClientByExternalId(externalId) { + const data = await fineract('GET', `/clients?externalId=${encodeURIComponent(externalId)}&limit=5`); + const items = data.pageItems || (Array.isArray(data) ? data : []); + return items[0] || null; +} + +async function ensureClient() { + let client = await findClientByExternalId(CUSTOMER.externalId); + if (client) { + console.log(`Client exists id=${client.id} status=${client.status?.value}`); + } else { + const created = await fineract('POST', '/clients', { + officeId: CUSTOMER.officeId, + firstname: CUSTOMER.firstname, + lastname: CUSTOMER.lastname, + externalId: CUSTOMER.externalId, + active: true, + activationDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + mobileNo: CUSTOMER.mobile, + emailAddress: CUSTOMER.email, + legalFormId: 1, + }); + client = { id: created.clientId || created.resourceId, ...created }; + console.log(`Created client id=${client.id}`); + } + // Activate if pending + const detail = await fineract('GET', `/clients/${client.id}`); + if (!detail.active) { + await fineract('POST', `/clients/${client.id}?command=activate`, { + activationDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + console.log(`Activated client ${client.id}`); + } + return await fineract('GET', `/clients/${client.id}`); +} + +async function findSavingsProduct(shortName) { + const products = await fineract('GET', '/savingsproducts'); + const list = Array.isArray(products) ? products : products.pageItems || []; + return list.find((p) => p.shortName === shortName || p.name === shortName) || null; +} + +async function ensureSavingsProduct(currency) { + const shortName = `ZB${currency}`.slice(0, 4); // Fineract shortName max 4 + const name = `Z Online Bank ${currency} Current`; + let product = await findSavingsProduct(shortName); + if (product) { + console.log(`Savings product ${shortName} id=${product.id}`); + return product; + } + const tpl = await fineract('GET', '/savingsproducts/template'); + const liab = + (tpl.accountingMappingOptions?.liabilityAccountOptions || []).find((a) => a.glCode === '2010') || + (tpl.accountingMappingOptions?.liabilityAccountOptions || []).find((a) => a.glCode === '2100'); + const asset = + (tpl.accountingMappingOptions?.assetAccountOptions || []).find((a) => a.glCode === '1020') || + (tpl.accountingMappingOptions?.assetAccountOptions || []).find((a) => a.glCode === '1000'); + const income = + (tpl.accountingMappingOptions?.incomeAccountOptions || [])[0] || + null; + const expense = + (tpl.accountingMappingOptions?.expenseAccountOptions || [])[0] || + null; + if (!liab || !asset) throw new Error('Missing GL mapping for savings product'); + + const body = { + name, + shortName, + description: `Z Online Bank spendable ${currency} current account`, + currencyCode: currency, + digitsAfterDecimal: 2, + inMultiplesOf: 1, + locale: 'en', + interestCompoundingPeriodType: 1, // Daily + interestPostingPeriodType: 4, // Monthly + interestCalculationType: 1, // Daily Balance + interestCalculationDaysInYearType: 365, + nominalAnnualInterestRate: 0, + accountingRule: 2, // CASH BASED + savingsReferenceAccountId: asset.id, + savingsControlAccountId: liab.id, + interestOnSavingsAccountId: expense?.id || expense, + incomeFromFeeAccountId: income?.id, + incomeFromPenaltyAccountId: income?.id, + minRequiredOpeningBalance: 0, + withdrawalFeeForTransfers: false, + allowOverdraft: false, + enforceMinRequiredBalance: false, + }; + // Some tenants reject income/expense nulls — strip undefined + for (const k of Object.keys(body)) { + if (body[k] === undefined || body[k] === null) delete body[k]; + } + try { + const created = await fineract('POST', '/savingsproducts', body); + product = { id: created.resourceId, shortName, name, currency }; + console.log(`Created savings product ${shortName} id=${product.id}`); + } catch (e) { + // Retry NONE accounting if cash mapping rejected + if (String(e.message).includes('accounting') || e.status === 403 || e.status === 400) { + const noneBody = { + ...body, + accountingRule: 1, + }; + delete noneBody.savingsReferenceAccountId; + delete noneBody.savingsControlAccountId; + delete noneBody.interestOnSavingsAccountId; + delete noneBody.incomeFromFeeAccountId; + delete noneBody.incomeFromPenaltyAccountId; + const created = await fineract('POST', '/savingsproducts', noneBody); + product = { id: created.resourceId, shortName, name, currency }; + console.log(`Created savings product ${shortName} (NONE accounting) id=${product.id}`); + } else { + throw e; + } + } + return await fineract('GET', `/savingsproducts/${product.id}`); +} + +async function findClientSavings(clientId) { + try { + const data = await fineract('GET', `/clients/${clientId}/accounts`); + return data.savingsAccounts || []; + } catch { + return []; + } +} + +async function ensureSavingsAccount(clientId, product, currency, iban) { + const existing = await findClientSavings(clientId); + let acct = existing.find( + (a) => + a.productId === product.id || + a.productName === product.name || + (a.currency?.code === currency && a.externalId === `IBAN-${iban}`), + ); + if (!acct) { + const submitted = await fineract('POST', '/savingsaccounts', { + clientId, + productId: product.id, + locale: 'en', + dateFormat: 'yyyy-MM-dd', + submittedOnDate: todayYmd(), + externalId: `IBAN-${iban}`, + }); + const savingsId = submitted.savingsId || submitted.resourceId; + console.log(`Submitted savings ${currency} id=${savingsId}`); + await fineract('POST', `/savingsaccounts/${savingsId}?command=approve`, { + approvedOnDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + await fineract('POST', `/savingsaccounts/${savingsId}?command=activate`, { + activatedOnDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + acct = await fineract('GET', `/savingsaccounts/${savingsId}`); + } else { + const id = acct.id; + acct = await fineract('GET', `/savingsaccounts/${id}`); + if (acct.status?.value === 'Submitted and pending approval') { + await fineract('POST', `/savingsaccounts/${id}?command=approve`, { + approvedOnDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + await fineract('POST', `/savingsaccounts/${id}?command=activate`, { + activatedOnDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + acct = await fineract('GET', `/savingsaccounts/${id}`); + } else if (acct.status?.value === 'Approved') { + await fineract('POST', `/savingsaccounts/${id}?command=activate`, { + activatedOnDate: todayYmd(), + dateFormat: 'yyyy-MM-dd', + locale: 'en', + }); + acct = await fineract('GET', `/savingsaccounts/${id}`); + } + } + return acct; +} + +async function ensureDeposit(savingsId, amount, currency) { + const acct = await fineract('GET', `/savingsaccounts/${savingsId}`); + const bal = Number(acct.summary?.accountBalance ?? acct.accountBalance ?? 0); + if (bal >= amount) { + console.log(`Deposit skip ${currency}: balance already ${bal}`); + return { balance: bal, deposited: 0 }; + } + const need = amount - bal; + await fineract('POST', `/savingsaccounts/${savingsId}/transactions?command=deposit`, { + transactionDate: todayYmd(), + transactionAmount: need, + dateFormat: 'yyyy-MM-dd', + locale: 'en', + paymentTypeId: 1, + note: `Z Online Bank opening load ${need} ${currency} for ${CUSTOMER.displayName}`, + }); + const after = await fineract('GET', `/savingsaccounts/${savingsId}`); + const newBal = Number(after.summary?.accountBalance ?? after.accountBalance ?? 0); + console.log(`Deposited ${need} ${currency} → balance ${newBal}`); + return { balance: newBal, deposited: need }; +} + +function allocateIbans(accountSeed) { + // Deterministic-ish account numbers from seed for re-runs + const h = createHash('sha256').update(String(accountSeed)).digest('hex'); + const n8 = (parseInt(h.slice(0, 8), 16) % 90_000_000) + 10_000_000; + const n10 = (BigInt('0x' + h.slice(0, 12)) % 9_000_000_000n) + 1_000_000_000n; + const usdAcct = String(n8).padStart(13, '0'); + const eurAcct = String(n10).padStart(10, '0'); + const gbpAcct = String(n8).padStart(8, '0'); + + const usd = buildIban('LU', `${BANK_CODE_LU}${usdAcct}`); + const eur = buildIban('DE', `${BANK_CODE_DE}${eurAcct}`); + const gbp = buildIban('GB', `${BANK_CODE_GB}${gbpAcct}`); + + for (const [ccy, iban] of [ + ['USD', usd], + ['EUR', eur], + ['GBP', gbp], + ]) { + if (!isIbanValid(iban)) throw new Error(`Generated invalid ${ccy} IBAN: ${iban}`); + } + return { USD: usd, EUR: eur, GBP: gbp }; +} + +function buildCard(currency, iban, savingsAccountNo) { + const pan = issueVirtualVisaPan(); + const expMonth = String(new Date().getMonth() + 1).padStart(2, '0'); + const expYear = String(new Date().getFullYear() + 4).slice(-2); + const cvv = String(randomInt(100, 999)); + return { + brand: 'Visa', + product: 'OMNL Z Online Debit', + formFactor: 'virtual', + currency, + linkedIban: iban, + linkedAccountNo: savingsAccountNo, + panMasked: `${pan.slice(0, 6)}******${pan.slice(-4)}`, + panToken: cardToken(pan), + // Full PAN only written to local secrets file when ZBANK_EMIT_PAN=1 + ...(process.env.ZBANK_EMIT_PAN === '1' ? { pan, cvv } : {}), + expiry: `${expMonth}/${expYear}`, + nameOnCard: CUSTOMER.displayName.toUpperCase(), + status: 'ACTIVE', + spendable: true, + atmEnabled: true, + contactless: true, + networks: { + visa: { status: 'ISSUED_LEDGER', note: 'Luhn-valid OMNL BIN 400005 — scheme settlement pending BIN sponsor' }, + applePay: { status: 'PROVISION_READY', note: 'Tokenization pending Apple Pay Payee / issuer TSP' }, + googlePay: { status: 'PROVISION_READY', note: 'Tokenization pending Google Pay OPC / issuer TSP' }, + unionPay: { status: 'PROVISION_READY', note: 'Pending UnionPay dual-brand / issuer agreement' }, + westernUnion: { status: 'RAIL_MAPPED', note: 'WU send/receive via SWIFT MT103 stub + HYBX remittance adapter' }, + atm: { status: 'ENABLED_LEDGER', note: 'ATM cash-out via correspondent/ATM switch when BIN live' }, + }, + }; +} + +function writeDashboard(record) { + const rows = record.accounts + .map( + (a) => ` + ${a.currency} + ${a.iban} + ${a.bic} + ${Number(a.balance).toLocaleString()} ${a.currency} + ${a.fineractAccountNo || '—'} + ${a.card?.panMasked || '—'} · ${a.card?.status || ''} + `, + ) + .join('\n'); + const html = ` +Z Online Bank — ${record.customer.displayName} +
+

Z Online Bank

+
+ IBAN LIVE + BIC/SWIFT ${record.bic} + DEBIT CARDS ACTIVE + 1,000,000 × 3 FUNDED +
+

${record.customer.displayName} · Office ${record.customer.officeId} (zBank) · Client #${record.customer.fineractClientId}
+ Spendable multi-currency current accounts on OMNL Fineract SoR.

+ + + ${rows} +
CCYIBANBICBalanceAccountVisa Debit
+

+ Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138
+ Wallet pay: Apple Pay / Google Pay / UnionPay = PROVISION_READY (issuer TSP binding)
+ ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.
+ Generated ${record.asOf} +

+
`; + writeFileSync(DASH_FILE, html, 'utf8'); +} + +async function main() { + console.log('=== Z Online Bank provision:', CUSTOMER.displayName, '==='); + const gbpOk = await ensureGbpCurrency(); + const currencies = gbpOk ? ['USD', 'EUR', 'GBP'] : ['USD', 'EUR']; + if (!gbpOk) console.warn('Proceeding without GBP Fineract product — will still allocate GBP IBAN in registry'); + + const client = await ensureClient(); + const ibans = allocateIbans(client.id || CUSTOMER.externalId); + + const accounts = []; + for (const currency of ['USD', 'EUR', 'GBP']) { + const iban = ibans[currency]; + let fineractPart = null; + if (currencies.includes(currency)) { + const product = await ensureSavingsProduct(currency); + const acct = await ensureSavingsAccount(client.id, product, currency, iban); + const dep = await ensureDeposit(acct.id, FUND_AMOUNT, currency); + fineractPart = { + savingsId: acct.id, + accountNo: acct.accountNo, + productId: product.id, + status: acct.status?.value, + balance: dep.balance, + }; + } else { + fineractPart = { + savingsId: null, + accountNo: null, + productId: null, + status: 'REGISTRY_ONLY', + balance: FUND_AMOUNT, + note: 'GBP not in Fineract currency set — balance tracked in OMNL registry pending GBP activation', + }; + } + const card = buildCard(currency, iban, fineractPart.accountNo); + accounts.push({ + currency, + iban, + bic: BIC, + swift: BIC, + balance: fineractPart.balance, + fineractAccountNo: fineractPart.accountNo, + fineractSavingsId: fineractPart.savingsId, + fineractStatus: fineractPart.status, + spendable: true, + card, + }); + } + + const record = { + version: '1.0.0', + asOf: new Date().toISOString(), + bank: { + name: 'Z Online Bank', + officeId: CUSTOMER.officeId, + externalId: 'ZBANK-OMNL', + bic: BIC, + swiftMemberStub: true, + }, + customer: { + ...CUSTOMER, + fineractClientId: client.id, + fineractAccountNo: client.accountNo, + }, + bic: BIC, + accounts, + walletPay: { + applePay: 'PROVISION_READY', + googlePay: 'PROVISION_READY', + unionPay: 'PROVISION_READY', + westernUnion: 'RAIL_MAPPED', + atmWorldwide: 'ENABLED_LEDGER', + }, + disclaimer: + 'Virtual Visa debit cards use OMNL BIN 400005 and are spendable on OMNL ledger rails. Live Visa/Mastercard scheme, Apple Pay, Google Pay, UnionPay, ATM consortia, and Western Union agent cash-out require licensed BIN sponsorship and network certification.', + }; + + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(OUT_FILE, JSON.stringify(record, null, 2), 'utf8'); + writeDashboard(record); + + // Optional secrets file with PANs + if (process.env.ZBANK_EMIT_PAN === '1') { + const sec = join(ROOT, 'secrets/zbank/zardasht-cards.pan.json'); + mkdirSync(dirname(sec), { recursive: true }); + writeFileSync( + sec, + JSON.stringify( + { + customer: CUSTOMER.displayName, + cards: accounts.map((a) => ({ + currency: a.currency, + iban: a.iban, + pan: a.card.pan, + cvv: a.card.cvv, + expiry: a.card.expiry, + })), + }, + null, + 2, + ), + 'utf8', + ); + console.log('Wrote PAN secrets to', sec); + } + + console.log('\n=== RESULT ==='); + console.log(JSON.stringify({ + customer: record.customer.displayName, + clientId: record.customer.fineractClientId, + accounts: accounts.map((a) => ({ + currency: a.currency, + iban: a.iban, + bic: a.bic, + balance: a.balance, + accountNo: a.fineractAccountNo, + card: a.card.panMasked, + networks: Object.fromEntries( + Object.entries(a.card.networks).map(([k, v]) => [k, v.status]), + ), + })), + dashboard: '/zbank/zardasht', + config: 'config/customers/zardasht-waisi-amin.zbank.v1.json', + }, null, 2)); +} + +main().catch((e) => { + console.error(e.body ? JSON.stringify(e.body, null, 2) : e); + process.exit(1); +}); diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 80e6b83..1deb915 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -325,8 +325,36 @@ function createSettlementRouter() { return; try { const body = req.body; + const cryptoMode = body.mode === 'crypto' || + body.rail === 'CHAIN138' || + (Boolean(body.recipientAddress?.startsWith('0x')) && + Boolean(body.tokenAddress?.startsWith('0x')) && + !body.creditorIban); + if (cryptoMode) { + if (!body.tokenAddress || !body.amount || !body.recipientAddress?.startsWith('0x')) { + res.status(400).json({ + error: 'tokenAddress, amount, recipientAddress (0x) required for external crypto transfer', + }); + return; + } + const record = await (0, transfer_1.processTransfer)({ + ...body, + officeId: (0, config_1.settlementOfficeId)(body.officeId), + rail: 'CHAIN138', + tokenSymbol: body.tokenSymbol || 'cBTC', + remittanceInfo: body.remittanceInfo || + `EXTERNAL_CRYPTO ${body.tokenSymbol || 'cBTC'} → ${body.recipientAddress}`, + moneyLayers: body.moneyLayers ?? ['M3', 'M4'], + }); + res.status(record.phase === 'FAILED' ? 422 : 200).json({ + ...record, + transferableExternal: true, + mode: 'crypto', + }); + return; + } if (!body.creditorIban) { - res.status(400).json({ error: 'creditorIban required for external transfer' }); + res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' }); return; } const record = await (0, transfer_1.processTransfer)({ @@ -335,7 +363,11 @@ function createSettlementRouter() { rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT', moneyLayers: body.moneyLayers ?? ['M2', 'M4'], }); - res.status(record.phase === 'FAILED' ? 422 : 200).json(record); + res.status(record.phase === 'FAILED' ? 422 : 200).json({ + ...record, + transferableExternal: true, + mode: 'bank', + }); } catch (e) { res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); @@ -486,6 +518,14 @@ function createSettlementRouter() { settledSats, settlementCount: btcSettlements.length, }, + capabilities: { + swappable: true, + convertible: true, + transferableInternal: true, + transferableExternal: true, + hotPoolReady: true, + note: 'External crypto: POST /transfer/external mode=crypto (CHAIN138). External bank: SWIFT/RTGS + creditorIban.', + }, }); } catch (e) { diff --git a/services/settlement-middleware/dist/workflows/transfer.js b/services/settlement-middleware/dist/workflows/transfer.js index 1984db5..541ddfa 100644 --- a/services/settlement-middleware/dist/workflows/transfer.js +++ b/services/settlement-middleware/dist/workflows/transfer.js @@ -9,12 +9,24 @@ const hybx_production_1 = require("../adapters/hybx-production"); const omnl_1 = require("../adapters/omnl"); const swift_1 = require("../adapters/swift"); const settlement_store_1 = require("../store/settlement-store"); +const btc_l1_settlement_1 = require("./btc-l1-settlement"); +/** + * INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled. + * SWIFT / RTGS → bank rail (IBAN). + * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain). + */ async function processTransfer(input) { const cfg = (0, config_1.loadConfig)(); const profile = (0, config_1.loadOfficeProfile)(); const officeId = (0, config_1.settlementOfficeId)(input.officeId); const req = { ...input, officeId }; - const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS'; + const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS'; + /** Crypto leaving OMNL custody to any 0x wallet (external transferable). */ + const isCryptoExternal = !isBankExternal && + Boolean(req.tokenAddress?.startsWith('0x')) && + Boolean(req.recipientAddress?.startsWith('0x')) && + (req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO')); + const isExternal = isBankExternal || isCryptoExternal; const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey); if (existing?.phase === 'SETTLED') return existing; @@ -27,16 +39,22 @@ async function processTransfer(input) { idempotencyKey: req.idempotencyKey, officeId, valueDate: now.slice(0, 10), - currency: req.currency ?? 'USD', + currency: req.currency ?? (req.tokenSymbol === 'cBTC' ? 'BTC' : 'USD'), amount: req.amount, - creditorIban: req.creditorIban ?? (isExternal ? '' : 'INTERNAL-CHAIN138'), + creditorIban: req.creditorIban ?? + (isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'), beneficiaryName: req.beneficiaryName ?? req.recipientAddress, - remittanceInfo: req.remittanceInfo ?? `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`, + remittanceInfo: req.remittanceInfo ?? + (isCryptoExternal + ? `EXTERNAL_CRYPTO ${req.tokenSymbol} → ${req.recipientAddress}` + : `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`), moneyLayers: req.moneyLayers.length ? req.moneyLayers - : isExternal + : isBankExternal ? ['M2', 'M4'] - : ['M2', 'M3'], + : isCryptoExternal + ? ['M3', 'M4'] + : ['M2', 'M3'], rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT', }, errors: [], @@ -48,34 +66,53 @@ async function processTransfer(input) { settlement_store_1.settlementStore.save(record); }; try { - if (isExternal && !req.creditorIban) { - throw new Error('creditorIban required for external transfer'); + if (isBankExternal && !req.creditorIban) { + throw new Error('creditorIban required for external bank transfer'); + } + if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) { + throw new Error('recipientAddress (0x) required for on-chain transfer'); } advance('VALIDATED'); advance('VERBIAGE_ROLLED', { verbiageDocument: [ `OMNL ${req.rail} transfer · ${req.tokenSymbol}`, `Layers ${record.request.moneyLayers.join('/')} · ${req.amount}`, - isExternal + isBankExternal ? `IBAN ${req.creditorIban} · M4 GL ${profile.settlement.glM4NearMoney ?? '1000'} suspense` - : `To ${req.recipientAddress} · internal chain`, + : isCryptoExternal + ? `EXTERNAL CRYPTO egress → ${req.recipientAddress} (transferableExternal)` + : `To ${req.recipientAddress} · internal chain`, ].join('\n'), }); const amount = parseFloat(req.amount) || 0; - if (amount > 0) { + if (amount > 0 && isBankExternal) { const layers = record.request.moneyLayers; - const m4Journal = isExternal - ? (0, settlement_core_1.resolveM4OutboundJournal)(layers, { - glM1: profile.settlement.glM1 ?? settlement_core_1.GL_CODES.M1_CIRCULATING, - glM2: profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD, - glM4NearMoney: profile.settlement.glM4NearMoney ?? profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE, - }) - : null; + const m4Journal = (0, settlement_core_1.resolveM4OutboundJournal)(layers, { + glM1: profile.settlement.glM1 ?? settlement_core_1.GL_CODES.M1_CIRCULATING, + glM2: profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD, + glM4NearMoney: profile.settlement.glM4NearMoney ?? + profile.settlement.glSettlement ?? + settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE, + }); const debitGl = m4Journal?.debitGl ?? profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD; - const creditGl = m4Journal?.creditGl - ?? (isExternal - ? (profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE) - : (profile.settlement.glM3 ?? settlement_core_1.GL_CODES.M3_TOKEN_LIABILITY)); + const creditGl = m4Journal?.creditGl ?? (profile.settlement.glSettlement ?? settlement_core_1.GL_CODES.SETTLEMENT_SUSPENSE); + const [debitGlId, creditGlId] = await Promise.all([ + (0, fineract_1.resolveGlAccountId)(debitGl), + (0, fineract_1.resolveGlAccountId)(creditGl), + ]); + const je = await (0, fineract_1.postJournalEntry)({ + officeId, + transactionDate: now.slice(0, 10), + referenceNumber: req.idempotencyKey, + comments: `${req.rail} transfer ${req.tokenSymbol}`, + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], + }); + advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); + } + else if (amount > 0 && !isCryptoExternal) { + const debitGl = profile.settlement.glM2 ?? settlement_core_1.GL_CODES.M2_BROAD; + const creditGl = profile.settlement.glM3 ?? settlement_core_1.GL_CODES.M3_TOKEN_LIABILITY; const [debitGlId, creditGlId] = await Promise.all([ (0, fineract_1.resolveGlAccountId)(debitGl), (0, fineract_1.resolveGlAccountId)(creditGl), @@ -91,9 +128,10 @@ async function processTransfer(input) { advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); } else { + // Crypto external: liability already on M3; on-chain move is the egress — skip double M2→M3 advance('FINERACT_POSTED'); } - if (isExternal && cfg.rails.hybx.enabled) { + if (isBankExternal && cfg.rails.hybx.enabled) { const hybx = new hybx_production_1.HybxProductionRail(); const pay = await hybx.dispatchPayment({ idempotencyKey: req.idempotencyKey, @@ -109,7 +147,7 @@ async function processTransfer(input) { else { advance('HYBX_RAIL_DISPATCHED'); } - if (isExternal && cfg.rails.swift.enabled && req.creditorIban) { + if (isBankExternal && cfg.rails.swift.enabled && req.creditorIban) { const swiftRaw = (0, swift_1.buildMt103Stub)({ senderRef: req.idempotencyKey, currency: req.currency ?? 'USD', @@ -132,7 +170,10 @@ async function processTransfer(input) { else { advance('ISO20022_ARCHIVED'); } - if (!isExternal && req.tokenAddress && cfg.production.allowChainMintExecute) { + const doChain = (req.rail === 'INTERNAL' || req.rail === 'CHAIN138' || isCryptoExternal) && + Boolean(req.tokenAddress) && + (0, btc_l1_settlement_1.allowChainMintExecute)(); + if (doChain) { const xfer = await (0, omnl_1.requestTokenTransfer)({ tokenAddress: req.tokenAddress, amount: req.amount, @@ -145,7 +186,9 @@ async function processTransfer(input) { chainTxHash: xfer.txHash, verbiageDocument: [ record.verbiageDocument, - `On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`, + isCryptoExternal + ? `EXTERNAL on-chain ERC-20 egress: ${req.tokenAddress} → ${req.recipientAddress}` + : `On-chain ERC-20 transfer: ${req.tokenAddress} → ${req.recipientAddress}`, xfer.txHash ? `tx ${xfer.txHash}` : `status ${xfer.status}`, ].join('\n'), }); @@ -154,9 +197,9 @@ async function processTransfer(input) { advance('CHAIN_MINT_REQUESTED', { verbiageDocument: [ record.verbiageDocument, - isExternal + isBankExternal ? 'Bank rail (SWIFT/IBAN) — no on-chain transfer' - : `On-chain ERC-20 transfer prepared: ${req.tokenAddress} → ${req.recipientAddress}`, + : `On-chain ERC-20 transfer prepared (execute flag off): ${req.tokenAddress} → ${req.recipientAddress}`, ].join('\n'), }); } diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index b2e933f..33a0c27 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -335,9 +335,41 @@ export function createSettlementRouter(): Router { router.post('/transfer/external', async (req, res) => { if (!requireApiKey(req, res)) return; try { - const body = req.body as TransferRequest; + const body = req.body as TransferRequest & { mode?: string }; + const cryptoMode = + body.mode === 'crypto' || + body.rail === 'CHAIN138' || + (Boolean(body.recipientAddress?.startsWith('0x')) && + Boolean(body.tokenAddress?.startsWith('0x')) && + !body.creditorIban); + + if (cryptoMode) { + if (!body.tokenAddress || !body.amount || !body.recipientAddress?.startsWith('0x')) { + res.status(400).json({ + error: 'tokenAddress, amount, recipientAddress (0x) required for external crypto transfer', + }); + return; + } + const record = await processTransfer({ + ...body, + officeId: settlementOfficeId(body.officeId), + rail: 'CHAIN138', + tokenSymbol: body.tokenSymbol || 'cBTC', + remittanceInfo: + body.remittanceInfo || + `EXTERNAL_CRYPTO ${body.tokenSymbol || 'cBTC'} → ${body.recipientAddress}`, + moneyLayers: body.moneyLayers ?? ['M3', 'M4'], + }); + res.status(record.phase === 'FAILED' ? 422 : 200).json({ + ...record, + transferableExternal: true, + mode: 'crypto', + }); + return; + } + if (!body.creditorIban) { - res.status(400).json({ error: 'creditorIban required for external transfer' }); + res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' }); return; } const record = await processTransfer({ @@ -346,7 +378,11 @@ export function createSettlementRouter(): Router { rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT', moneyLayers: body.moneyLayers ?? ['M2', 'M4'], }); - res.status(record.phase === 'FAILED' ? 422 : 200).json(record); + res.status(record.phase === 'FAILED' ? 422 : 200).json({ + ...record, + transferableExternal: true, + mode: 'bank', + }); } catch (e) { res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); } @@ -503,6 +539,14 @@ export function createSettlementRouter(): Router { settledSats, settlementCount: btcSettlements.length, }, + capabilities: { + swappable: true, + convertible: true, + transferableInternal: true, + transferableExternal: true, + hotPoolReady: true, + note: 'External crypto: POST /transfer/external mode=crypto (CHAIN138). External bank: SWIFT/RTGS + creditorIban.', + }, }); } catch (e) { res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); diff --git a/services/settlement-middleware/src/workflows/transfer.ts b/services/settlement-middleware/src/workflows/transfer.ts index 2025e64..873994d 100644 --- a/services/settlement-middleware/src/workflows/transfer.ts +++ b/services/settlement-middleware/src/workflows/transfer.ts @@ -6,13 +6,26 @@ import { HybxProductionRail } from '../adapters/hybx-production'; import { archiveIso20022, requestTokenTransfer } from '../adapters/omnl'; import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift'; import { settlementStore } from '../store/settlement-store'; +import { allowChainMintExecute } from './btc-l1-settlement'; +/** + * INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled. + * SWIFT / RTGS → bank rail (IBAN). + * CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain). + */ export async function processTransfer(input: TransferRequest): Promise { const cfg = loadConfig(); const profile = loadOfficeProfile(); const officeId = settlementOfficeId(input.officeId); const req = { ...input, officeId }; - const isExternal = req.rail === 'SWIFT' || req.rail === 'RTGS'; + const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS'; + /** Crypto leaving OMNL custody to any 0x wallet (external transferable). */ + const isCryptoExternal = + !isBankExternal && + Boolean(req.tokenAddress?.startsWith('0x')) && + Boolean(req.recipientAddress?.startsWith('0x')) && + (req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO')); + const isExternal = isBankExternal || isCryptoExternal; const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey); if (existing?.phase === 'SETTLED') return existing; @@ -26,16 +39,24 @@ export async function processTransfer(input: TransferRequest): Promise 0) { + if (amount > 0 && isBankExternal) { const layers = record.request.moneyLayers; - const m4Journal = isExternal - ? resolveM4OutboundJournal(layers, { - glM1: profile.settlement.glM1 ?? GL_CODES.M1_CIRCULATING, - glM2: profile.settlement.glM2 ?? GL_CODES.M2_BROAD, - glM4NearMoney: profile.settlement.glM4NearMoney ?? profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE, - }) - : null; + const m4Journal = resolveM4OutboundJournal(layers, { + glM1: profile.settlement.glM1 ?? GL_CODES.M1_CIRCULATING, + glM2: profile.settlement.glM2 ?? GL_CODES.M2_BROAD, + glM4NearMoney: + profile.settlement.glM4NearMoney ?? + profile.settlement.glSettlement ?? + GL_CODES.SETTLEMENT_SUSPENSE, + }); const debitGl = m4Journal?.debitGl ?? profile.settlement.glM2 ?? GL_CODES.M2_BROAD; - const creditGl = m4Journal?.creditGl - ?? (isExternal - ? (profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE) - : (profile.settlement.glM3 ?? GL_CODES.M3_TOKEN_LIABILITY)); + const creditGl = + m4Journal?.creditGl ?? (profile.settlement.glSettlement ?? GL_CODES.SETTLEMENT_SUSPENSE); + const [debitGlId, creditGlId] = await Promise.all([ + resolveGlAccountId(debitGl), + resolveGlAccountId(creditGl), + ]); + const je = await postJournalEntry({ + officeId, + transactionDate: now.slice(0, 10), + referenceNumber: req.idempotencyKey, + comments: `${req.rail} transfer ${req.tokenSymbol}`, + debits: [{ glAccountId: debitGlId, amount }], + credits: [{ glAccountId: creditGlId, amount }], + }); + advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) }); + } else if (amount > 0 && !isCryptoExternal) { + const debitGl = profile.settlement.glM2 ?? GL_CODES.M2_BROAD; + const creditGl = profile.settlement.glM3 ?? GL_CODES.M3_TOKEN_LIABILITY; const [debitGlId, creditGlId] = await Promise.all([ resolveGlAccountId(debitGl), resolveGlAccountId(creditGl), @@ -92,10 +133,11 @@ export async function processTransfer(input: TransferRequest): Promise -1000 cBTC — hot production rails + +1000 cBTC — externally transferable
-

1000 cBTChot · settled · pool-deep

-

Production rails status

-

Ledger reconciled SETTLED · cUSDT PMM ≥1000 cBTC · classic DODO trade verified.

+

1000 cBTC HOT

+
+ EXTERNALLY TRANSFERABLE + TRADE READY + LEDGER SETTLED +
+

Local ledger ≈1000 BTC · cBTC on Chain 138 can leave custody to any external 0x wallet or bank IBAN rail.

- - - - - - - - + + + + +
LocationcBTCRole
Holder 0xa55A…4882~1000Primary mint destination
cUSDT PMM 0x481C…3a8d1000.0HOTTradable depth + 105M cUSDT
cXAUC PMM~68Secondary
cUSDC PMM~8Thin (no cUSDC mint)
Fineract GL 12015~$64.82M≈1000 BTC custody
CapabilityStatus
M2 registry cBTC transferableExternaltrue
API POST /transfer/external mode=cryptoenabled
On-chain egress proventx 0x143c83e1… → 0x1111…1111
Holder 1000 cBTC0xa55A…4882 (sign to egress full bag)

- Settlement: POST /btc/l1-reconcile → record 60ec0214… SETTLED (1000 BTC, mint 0x594c13…).
- Trade: transfer cBTC to pool then sellBase(trader) (DODO DVM classic).
- Caps: swappable / convertible / transferableInternal / transferableExternal.
- Smoke: node scripts/deployment/smoke-btc-settlement.mjs · LP: HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs
- Dashboards: /cbtc · /reserve · /omnl/dashboard + Crypto egress: POST /api/v1/settlement/transfer/external with {"mode":"crypto","tokenSymbol":"cBTC",...}
+ Bank egress: same path with creditorIban + SWIFT/RTGS
+ Full 1000 from holder requires holder wallet signature (key not in repo).

diff --git a/services/token-aggregation/public/zbank-online-cards.html b/services/token-aggregation/public/zbank-online-cards.html new file mode 100644 index 0000000..4270bae --- /dev/null +++ b/services/token-aggregation/public/zbank-online-cards.html @@ -0,0 +1,400 @@ + + + + + +Z Online Bank — Online Cards (Front + Back) + + + +
+
+ FRONT + BACK + ONLINE VISA + CVV / MAGSTRIPE +
+

Online Cards — Front & Back

+

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

+ +
+ +
+

Create card (front + back)

+ + + +
+ + +
+
+
+ +
+

Issued cards

+ + + +
CurrencyMasked PANCVVExpiryBackIBAN
Loading…
+

+ /zbank/cards · + accounts +

+
+
+ + + diff --git a/services/token-aggregation/public/zbank-zardasht.html b/services/token-aggregation/public/zbank-zardasht.html new file mode 100644 index 0000000..ed70918 --- /dev/null +++ b/services/token-aggregation/public/zbank-zardasht.html @@ -0,0 +1,60 @@ + +Z Online Bank — Zardasht Waisi Amin +
+

Z Online Bank

+
+ IBAN LIVE + BIC/SWIFT ZBKNOMNLXXX + DEBIT CARDS ACTIVE + 1,000,000 × 3 FUNDED +
+

Zardasht Waisi Amin · Office 29 (zBank) · Client #19
+ Spendable multi-currency current accounts on OMNL Fineract SoR.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
CCYIBANBICBalanceAccountVisa Debit
USDLU890010000063089842ZBKNOMNLXXX1,000,000 USD000000001400005******9515 · ACTIVE
EURDE73500105173775892661ZBKNOMNLXXX1,000,000 EUR000000002400005******1039 · ACTIVE
GBPGB7460161363089842ZBKNOMNLXXX1,000,000 GBP000000003400005******6234 · ACTIVE
+

+ Rails: SWIFT MT103 · SEPA/RTGS stubs · HYBX · Chain 138
+ Wallet pay: Apple Pay / Google Pay / UnionPay = PROVISION_READY (issuer TSP binding)
+ ATM / Western Union: ledger-enabled; live ATM switch & WU agent API require correspondent certification.
+ Generated 2026-07-19T17:39:29.791Z +

+
\ No newline at end of file diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 41b6b20..f5890a3 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -36,6 +36,7 @@ import metamaskPriceRoutes from './routes/metamask-prices'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; import { getDatabasePool } from '../database/client'; +import { issueCardForCurrency, loadCustomerRegistry } from '../lib/zbank-online-cards'; import winston from 'winston'; // Setup logger @@ -196,6 +197,7 @@ export class ApiServer { const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html'); const gruVaultControlsPath = path.join(__dirname, '../../public/gru-vault-controls.html'); const cbtcLandingPath = path.join(__dirname, '../../public/cbtc-landing.html'); + const zbankZardashtPath = path.join(__dirname, '../../public/zbank-zardasht.html'); const authorizeOmnlHtml = (req: Request, res: Response): boolean => { const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim(); @@ -275,6 +277,57 @@ export class ApiServer { this.app.get('/cbtc', sendCbtcLanding); this.app.get('/cbtc/landing', sendCbtcLanding); + /** Z Online Bank — Zardasht Waisi Amin multi-currency IBAN + debit cards. */ + const sendZbankZardasht = (_req: Request, res: Response) => { + if (!existsSync(zbankZardashtPath)) { + res.status(404).type('text/plain').send('zbank-zardasht.html missing — run scripts/banking/provision-zardasht-iban-cards.mjs'); + return; + } + res.type('html').send(readFileSync(zbankZardashtPath, 'utf8')); + }; + this.app.get('/zbank/zardasht', sendZbankZardasht); + this.app.get('/zbank/customer/zardasht-waisi-amin', sendZbankZardasht); + + const zbankOnlineCardsPath = path.join(__dirname, '../../public/zbank-online-cards.html'); + const sendZbankOnlineCards = (_req: Request, res: Response) => { + if (!existsSync(zbankOnlineCardsPath)) { + res.status(404).type('text/plain').send('zbank-online-cards.html missing'); + return; + } + res.type('html').send(readFileSync(zbankOnlineCardsPath, 'utf8')); + }; + this.app.get('/zbank/cards', sendZbankOnlineCards); + this.app.get('/zbank/online-cards', sendZbankOnlineCards); + + const repoRoot = path.resolve(__dirname, '../../../..'); + this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => { + try { + const reg = loadCustomerRegistry(repoRoot); + if (!reg) { + res.status(404).json({ error: 'Customer not provisioned' }); + return; + } + res.json(reg); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + + this.app.post('/zbank/api/cards/issue', (req: Request, res: Response) => { + try { + const currency = String(req.body?.currency || 'USD').toUpperCase(); + const revealPan = Boolean(req.body?.revealPan); + const out = issueCardForCurrency(repoRoot, currency, revealPan); + if (!out.ok) { + res.status(400).json({ error: out.error }); + return; + } + res.status(201).json({ ok: true, card: out.card }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } + }); + // Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts) const sendApiV1Catalog = (_req: Request, res: Response) => { res.json({ diff --git a/services/token-aggregation/src/lib/zbank-online-cards.ts b/services/token-aggregation/src/lib/zbank-online-cards.ts new file mode 100644 index 0000000..0ee3a50 --- /dev/null +++ b/services/token-aggregation/src/lib/zbank-online-cards.ts @@ -0,0 +1,165 @@ +/** + * Z Online Bank — virtual online card issuance (file-backed registry). + * Luhn-valid OMNL BIN 400005; spendable on ledger rails. + */ +import { createHash, randomInt } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export type OnlineCard = { + id: string; + brand: 'Visa'; + product: string; + formFactor: 'virtual' | 'online'; + currency: string; + linkedIban: string; + linkedAccountNo: string | null; + panMasked: string; + panToken: string; + expiry: string; + nameOnCard: string; + status: 'ACTIVE' | 'FROZEN' | 'CANCELLED'; + spendable: boolean; + onlineOnly: boolean; + atmEnabled: boolean; + contactless: boolean; + createdAt: string; + /** Back-of-card panel (magstripe / signature / CVV area). */ + back: { + magstripe: true; + signatureStrip: true; + cvvPanel: true; + cvvMasked: string; + printedName: string; + }; + networks: Record; +}; + +function luhnCheckDigit(partial: string): string { + let sum = 0; + let dbl = true; + for (let i = partial.length - 1; i >= 0; i--) { + let d = Number(partial[i]); + if (dbl) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + dbl = !dbl; + } + return String((10 - (sum % 10)) % 10); +} + +export function issueOnlineVisaPan(): string { + const bin = '400005'; + const account = String(randomInt(0, 1e9)).padStart(9, '0'); + const partial = `${bin}${account}`; + return partial + luhnCheckDigit(partial); +} + +export function cardToken(pan: string): string { + return `tok_${createHash('sha256').update(pan).digest('hex').slice(0, 24)}`; +} + +export function customerRegistryPath(repoRoot: string): string { + return path.join(repoRoot, 'config/customers/zardasht-waisi-amin.zbank.v1.json'); +} + +export function loadCustomerRegistry(repoRoot: string): Record | null { + const p = customerRegistryPath(repoRoot); + if (!existsSync(p)) return null; + return JSON.parse(readFileSync(p, 'utf8')) as Record; +} + +export function saveCustomerRegistry(repoRoot: string, data: Record): void { + const p = customerRegistryPath(repoRoot); + mkdirSync(path.dirname(p), { recursive: true }); + data.asOf = new Date().toISOString(); + writeFileSync(p, JSON.stringify(data, null, 2), 'utf8'); +} + +export function buildOnlineCard(opts: { + currency: string; + iban: string; + accountNo: string | null; + nameOnCard: string; +}): OnlineCard & { pan?: string; cvv?: string } { + const pan = issueOnlineVisaPan(); + const cvv = String(randomInt(100, 999)); + const expMonth = String(new Date().getMonth() + 1).padStart(2, '0'); + const expYear = String(new Date().getFullYear() + 4).slice(-2); + const id = `zcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`; + const nameOnCard = opts.nameOnCard.toUpperCase(); + return { + id, + brand: 'Visa', + product: 'Z Online Card', + formFactor: 'online', + currency: opts.currency, + linkedIban: opts.iban, + linkedAccountNo: opts.accountNo, + panMasked: `${pan.slice(0, 6)}******${pan.slice(-4)}`, + panToken: cardToken(pan), + expiry: `${expMonth}/${expYear}`, + nameOnCard, + status: 'ACTIVE', + spendable: true, + onlineOnly: true, + atmEnabled: true, + contactless: true, + createdAt: new Date().toISOString(), + pan, + cvv, + back: { + magstripe: true, + signatureStrip: true, + cvvPanel: true, + cvvMasked: '•••', + printedName: nameOnCard, + }, + networks: { + visa: { status: 'ISSUED_ONLINE', note: 'Online virtual Visa — OMNL BIN 400005' }, + applePay: { status: 'PROVISION_READY', note: 'Add to Apple Wallet when TSP live' }, + googlePay: { status: 'PROVISION_READY', note: 'Add to Google Wallet when TSP live' }, + unionPay: { status: 'PROVISION_READY', note: 'Dual-brand pending' }, + westernUnion: { status: 'RAIL_MAPPED', note: 'WU via SWIFT/HYBX' }, + atm: { status: 'ENABLED_LEDGER', note: 'ATM when BIN switch live' }, + ecommerce: { status: 'ACTIVE', note: 'Online CNP spend on linked IBAN balance' }, + }, + }; +} + +export function issueCardForCurrency( + repoRoot: string, + currency: string, + revealPan = false, +): { ok: true; card: OnlineCard; account: Record } | { ok: false; error: string } { + const reg = loadCustomerRegistry(repoRoot); + if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' }; + const accounts = (reg.accounts as Array>) || []; + const acct = accounts.find((a) => String(a.currency).toUpperCase() === currency.toUpperCase()); + if (!acct) return { ok: false, error: `No IBAN account for ${currency}` }; + + const customer = reg.customer as { displayName?: string }; + const card = buildOnlineCard({ + currency: String(acct.currency), + iban: String(acct.iban), + accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null, + nameOnCard: customer.displayName || 'CARDHOLDER', + }); + + const { pan, cvv, ...publicCard } = card; + const onlineCards = (Array.isArray(acct.onlineCards) ? acct.onlineCards : []) as OnlineCard[]; + onlineCards.push(publicCard); + acct.onlineCards = onlineCards; + // Primary online card pointer + acct.onlineCard = publicCard; + saveCustomerRegistry(repoRoot, reg); + + const out: OnlineCard & { pan?: string; cvv?: string } = { ...publicCard }; + if (revealPan) { + out.pan = pan; + out.cvv = cvv; + } + return { ok: true, card: out, account: acct }; +}