feat(settlement): production Revolut, Wise, and Binance external rails
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 59s
CI/CD Pipeline / Security Scanning (push) Successful in 2m34s
CI/CD Pipeline / Lint and Format (push) Failing after 49s
CI/CD Pipeline / Terraform Validation (push) Failing after 22s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 26s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 19s
Validation / validate-genesis (push) Successful in 31s
Validation / validate-terraform (push) Failing after 27s
Validation / validate-kubernetes (push) Failing after 10s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m26s
Validation / validate-documentation (push) Failing after 18s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 59s
CI/CD Pipeline / Security Scanning (push) Successful in 2m34s
CI/CD Pipeline / Lint and Format (push) Failing after 49s
CI/CD Pipeline / Terraform Validation (push) Failing after 22s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 26s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 19s
Validation / validate-genesis (push) Successful in 31s
Validation / validate-terraform (push) Failing after 27s
Validation / validate-kubernetes (push) Failing after 10s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m26s
Validation / validate-documentation (push) Failing after 18s
Wire Business/Platform/Spot adapters with fail-closed live gates, status/probe/dispatch APIs, smoke script, and portal rails UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -114,3 +114,8 @@ lib/dodo-gassaving-pool/
|
||||
lib/dodo-limit-order/
|
||||
lib/dodo-v3/
|
||||
lib/deploy-starter.txt
|
||||
|
||||
# External rail credentials (keep *.example tracked if forced)
|
||||
secrets/**/credentials.env
|
||||
!secrets/**/credentials.env.example
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
31
config/rails-revolut-wise-binance.env.example
Normal file
31
config/rails-revolut-wise-binance.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Revolut Business + Wise Platform + Binance Spot (production)
|
||||
# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored).
|
||||
# Do not commit real tokens.
|
||||
|
||||
# --- Revolut Business API ---
|
||||
# https://developer.revolut.com/docs/business/business-api
|
||||
REVOLUT_ENABLED=1
|
||||
REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0
|
||||
# Sandbox: https://sandbox-b2b.revolut.com/api/1.0
|
||||
REVOLUT_ACCESS_TOKEN=
|
||||
REVOLUT_ACCOUNT_ID=
|
||||
SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0
|
||||
|
||||
# --- Wise / TransferWise ---
|
||||
# https://docs.wise.com/api-docs
|
||||
WISE_ENABLED=1
|
||||
WISE_API_BASE_URL=https://api.transferwise.com
|
||||
# Sandbox: https://api.sandbox.transferwise.tech
|
||||
WISE_API_TOKEN=
|
||||
WISE_PROFILE_ID=
|
||||
SETTLEMENT_ALLOW_WISE_PRODUCTION=0
|
||||
|
||||
# --- Binance Spot ---
|
||||
# https://binance-docs.github.io/apidocs/spot/en/
|
||||
BINANCE_ENABLED=1
|
||||
BINANCE_API_BASE_URL=https://api.binance.com
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0
|
||||
# Required for live coin withdrawals:
|
||||
BINANCE_ALLOW_WITHDRAW=0
|
||||
@@ -0,0 +1,31 @@
|
||||
# Revolut Business + Wise Platform + Binance Spot (production)
|
||||
# Copy live values into secrets/revolut|wise|binance/credentials.env (gitignored).
|
||||
# Do not commit real tokens.
|
||||
|
||||
# --- Revolut Business API ---
|
||||
# https://developer.revolut.com/docs/business/business-api
|
||||
REVOLUT_ENABLED=1
|
||||
REVOLUT_API_BASE_URL=https://b2b.revolut.com/api/1.0
|
||||
# Sandbox: https://sandbox-b2b.revolut.com/api/1.0
|
||||
REVOLUT_ACCESS_TOKEN=
|
||||
REVOLUT_ACCOUNT_ID=
|
||||
SETTLEMENT_ALLOW_REVOLUT_PRODUCTION=0
|
||||
|
||||
# --- Wise / TransferWise ---
|
||||
# https://docs.wise.com/api-docs
|
||||
WISE_ENABLED=1
|
||||
WISE_API_BASE_URL=https://api.transferwise.com
|
||||
# Sandbox: https://api.sandbox.transferwise.tech
|
||||
WISE_API_TOKEN=
|
||||
WISE_PROFILE_ID=
|
||||
SETTLEMENT_ALLOW_WISE_PRODUCTION=0
|
||||
|
||||
# --- Binance Spot ---
|
||||
# https://binance-docs.github.io/apidocs/spot/en/
|
||||
BINANCE_ENABLED=1
|
||||
BINANCE_API_BASE_URL=https://api.binance.com
|
||||
BINANCE_API_KEY=
|
||||
BINANCE_API_SECRET=
|
||||
SETTLEMENT_ALLOW_BINANCE_PRODUCTION=0
|
||||
# Required for live coin withdrawals:
|
||||
BINANCE_ALLOW_WITHDRAW=0
|
||||
7
packages/settlement-core/dist/types.d.ts
vendored
7
packages/settlement-core/dist/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/** OMNL central-bank settlement domain types */
|
||||
export type MoneyLayer = 'M0' | 'M1' | 'M2' | 'M3' | 'M4' | 'M00';
|
||||
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138';
|
||||
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138' | 'REVOLUT' | 'WISE' | 'BINANCE';
|
||||
export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
|
||||
export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910';
|
||||
export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'SETTLED' | 'FAILED';
|
||||
@@ -57,9 +57,10 @@ export type TransferRequest = {
|
||||
recipientAddress: string;
|
||||
senderAddress?: string;
|
||||
moneyLayers: MoneyLayer[];
|
||||
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS';
|
||||
/** External SWIFT/HYBX beneficiary IBAN when rail is external */
|
||||
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE';
|
||||
/** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */
|
||||
creditorIban?: string;
|
||||
creditorBic?: string;
|
||||
beneficiaryName?: string;
|
||||
remittanceInfo?: string;
|
||||
currency?: string;
|
||||
|
||||
@@ -10,7 +10,10 @@ export type PaymentRail =
|
||||
| 'ACH'
|
||||
| 'FEDWIRE'
|
||||
| 'INTERNAL'
|
||||
| 'CHAIN138';
|
||||
| 'CHAIN138'
|
||||
| 'REVOLUT'
|
||||
| 'WISE'
|
||||
| 'BINANCE';
|
||||
|
||||
export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
|
||||
|
||||
@@ -82,9 +85,10 @@ export type TransferRequest = {
|
||||
recipientAddress: string;
|
||||
senderAddress?: string;
|
||||
moneyLayers: MoneyLayer[];
|
||||
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS';
|
||||
/** External SWIFT/HYBX beneficiary IBAN when rail is external */
|
||||
rail: 'INTERNAL' | 'CHAIN138' | 'SWIFT' | 'RTGS' | 'REVOLUT' | 'WISE' | 'BINANCE';
|
||||
/** External SWIFT/HYBX/Revolut/Wise beneficiary IBAN when rail is external */
|
||||
creditorIban?: string;
|
||||
creditorBic?: string;
|
||||
beneficiaryName?: string;
|
||||
remittanceInfo?: string;
|
||||
currency?: string;
|
||||
|
||||
147
scripts/rails/smoke-revolut-wise-binance.mjs
Normal file
147
scripts/rails/smoke-revolut-wise-binance.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Smoke-test Revolut Business + Wise + Binance credentials.
|
||||
* Loads secrets/{revolut,wise,binance}/credentials.env then process.env.
|
||||
* Usage: node scripts/rails/smoke-revolut-wise-binance.mjs
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
function loadEnvFile(rel) {
|
||||
const p = path.join(root, rel);
|
||||
if (!fs.existsSync(p)) return {};
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || line.trim().startsWith('#')) continue;
|
||||
const i = line.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyEnv(obj) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v && (!(k in process.env) || !process.env[k])) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
function redact(s) {
|
||||
if (!s) return '(empty)';
|
||||
if (s.length <= 10) return '***';
|
||||
return `${s.slice(0, 4)}…${s.slice(-4)} (len=${s.length})`;
|
||||
}
|
||||
|
||||
applyEnv(loadEnvFile('.env'));
|
||||
applyEnv(loadEnvFile('secrets/revolut/credentials.env'));
|
||||
applyEnv(loadEnvFile('secrets/wise/credentials.env'));
|
||||
applyEnv(loadEnvFile('secrets/binance/credentials.env'));
|
||||
|
||||
async function smokeRevolut() {
|
||||
const token = process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN;
|
||||
const base = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
|
||||
if (!token) return { ok: false, configured: false, error: 'REVOLUT_ACCESS_TOKEN missing' };
|
||||
const res = await fetch(`${base}/accounts`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { raw: text.slice(0, 160) };
|
||||
}
|
||||
const accounts = Array.isArray(body) ? body : body?.accounts ?? [];
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
configured: true,
|
||||
http: res.status,
|
||||
token: redact(token),
|
||||
accountCount: Array.isArray(accounts) ? accounts.length : null,
|
||||
accountIdSet: Boolean(process.env.REVOLUT_ACCOUNT_ID),
|
||||
liveGate: process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
|
||||
error: res.ok ? undefined : body?.message || text.slice(0, 180),
|
||||
};
|
||||
}
|
||||
|
||||
async function smokeWise() {
|
||||
const token = process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN;
|
||||
const base = (process.env.WISE_API_BASE_URL || 'https://api.transferwise.com').replace(/\/$/, '');
|
||||
if (!token) return { ok: false, configured: false, error: 'WISE_API_TOKEN missing' };
|
||||
const res = await fetch(`${base}/v1/profiles`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { raw: text.slice(0, 160) };
|
||||
}
|
||||
const profiles = Array.isArray(body) ? body : [];
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
configured: true,
|
||||
http: res.status,
|
||||
token: redact(token),
|
||||
profileCount: profiles.length,
|
||||
profileIdSet: Boolean(process.env.WISE_PROFILE_ID),
|
||||
liveGate: process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
|
||||
error: res.ok ? undefined : body?.error || body?.message || text.slice(0, 180),
|
||||
};
|
||||
}
|
||||
|
||||
async function smokeBinance() {
|
||||
const key = process.env.BINANCE_API_KEY;
|
||||
const secret = process.env.BINANCE_API_SECRET;
|
||||
const base = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
|
||||
if (!key || !secret) return { ok: false, configured: false, error: 'BINANCE_API_KEY/SECRET missing' };
|
||||
|
||||
const ping = await fetch(`${base}/api/v3/ping`);
|
||||
const timestamp = Date.now();
|
||||
const qs = `timestamp=${timestamp}`;
|
||||
const sig = crypto.createHmac('sha256', secret).update(qs).digest('hex');
|
||||
const res = await fetch(`${base}/api/v3/account?${qs}&signature=${sig}`, {
|
||||
headers: { 'X-MBX-APIKEY': key, Accept: 'application/json' },
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = { raw: text.slice(0, 160) };
|
||||
}
|
||||
const funded = (body.balances || []).filter((b) => Number(b.free) > 0).length;
|
||||
return {
|
||||
ok: ping.ok && res.status >= 200 && res.status < 300,
|
||||
configured: true,
|
||||
http: res.status,
|
||||
ping: ping.status,
|
||||
apiKey: redact(key),
|
||||
fundedAssets: funded,
|
||||
canTrade: body.canTrade,
|
||||
liveGate: process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
|
||||
withdrawGate: process.env.BINANCE_ALLOW_WITHDRAW === '1',
|
||||
error: res.ok ? undefined : body?.msg || text.slice(0, 180),
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
asOf: new Date().toISOString(),
|
||||
revolut: await smokeRevolut(),
|
||||
wise: await smokeWise(),
|
||||
binance: await smokeBinance(),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
const anyConfigured = report.revolut.configured || report.wise.configured || report.binance.configured;
|
||||
const allOk =
|
||||
(!report.revolut.configured || report.revolut.ok) &&
|
||||
(!report.wise.configured || report.wise.ok) &&
|
||||
(!report.binance.configured || report.binance.ok);
|
||||
process.exit(anyConfigured && allOk ? 0 : anyConfigured ? 2 : 3);
|
||||
114
services/settlement-middleware/dist/adapters/binance-exchange.js
vendored
Normal file
114
services/settlement-middleware/dist/adapters/binance-exchange.js
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.BinanceExchangeRail = void 0;
|
||||
exports.loadBinanceConfig = loadBinanceConfig;
|
||||
/**
|
||||
* Binance Spot API (production) — account / withdraw / ticker.
|
||||
* Docs: https://binance-docs.github.io/apidocs/spot/en/
|
||||
* Auth: API key + HMAC SHA256 secret.
|
||||
*/
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
function loadBinanceConfig() {
|
||||
const apiKey = (process.env.BINANCE_API_KEY || '').trim();
|
||||
const apiSecret = (process.env.BINANCE_API_SECRET || '').trim();
|
||||
const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
class BinanceExchangeRail {
|
||||
config;
|
||||
http;
|
||||
constructor(cfg = loadBinanceConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios_1.default.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'X-MBX-APIKEY': cfg.apiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
isReady() {
|
||||
return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret);
|
||||
}
|
||||
sign(query) {
|
||||
return crypto_1.default.createHmac('sha256', this.config.apiSecret).update(query).digest('hex');
|
||||
}
|
||||
async signedGet(path, params = {}) {
|
||||
const timestamp = Date.now();
|
||||
const qs = new URLSearchParams({
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
timestamp: String(timestamp),
|
||||
}).toString();
|
||||
const signature = this.sign(qs);
|
||||
const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`);
|
||||
return data;
|
||||
}
|
||||
async signedPost(path, params = {}) {
|
||||
const timestamp = Date.now();
|
||||
const body = new URLSearchParams({
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
timestamp: String(timestamp),
|
||||
});
|
||||
const signature = this.sign(body.toString());
|
||||
body.append('signature', signature);
|
||||
const { data } = await this.http.post(path, body.toString(), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
async ping() {
|
||||
const { status } = await this.http.get('/api/v3/ping');
|
||||
return status === 200;
|
||||
}
|
||||
async accountInfo() {
|
||||
if (!this.isReady())
|
||||
throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)');
|
||||
const data = await this.signedGet('/api/v3/account');
|
||||
const balances = (data.balances || [])
|
||||
.filter((b) => Number(b.free) > 0)
|
||||
.slice(0, 20)
|
||||
.map((b) => ({ asset: b.asset, free: b.free }));
|
||||
return { balances, canTrade: Boolean(data.canTrade) };
|
||||
}
|
||||
async tickerPrice(symbol) {
|
||||
const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } });
|
||||
return { symbol: data.symbol, price: data.price };
|
||||
}
|
||||
/**
|
||||
* Withdraw to external address (requires withdrawal permission on API key).
|
||||
* Gate with BINANCE_ALLOW_WITHDRAW=1 in production.
|
||||
*/
|
||||
async withdraw(payload) {
|
||||
if (!this.isReady())
|
||||
throw new Error('Binance not configured');
|
||||
if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') {
|
||||
throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals');
|
||||
}
|
||||
const params = {
|
||||
coin: payload.coin.toUpperCase(),
|
||||
amount: payload.amount,
|
||||
address: payload.address,
|
||||
};
|
||||
if (payload.network)
|
||||
params.network = payload.network;
|
||||
if (payload.name)
|
||||
params.name = payload.name.slice(0, 30);
|
||||
const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params);
|
||||
return {
|
||||
id: String(data?.id ?? data?.withdrawOrderId ?? ''),
|
||||
status: 'SUBMITTED',
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.BinanceExchangeRail = BinanceExchangeRail;
|
||||
137
services/settlement-middleware/dist/adapters/external-banks-hub.js
vendored
Normal file
137
services/settlement-middleware/dist/adapters/external-banks-hub.js
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getExternalRailsStatus = getExternalRailsStatus;
|
||||
exports.probeExternalRails = probeExternalRails;
|
||||
exports.dispatchExternalBankOrExchange = dispatchExternalBankOrExchange;
|
||||
/**
|
||||
* External banks & exchange hub — Revolut Business + Wise + Binance.
|
||||
* Fail-closed until credentials present; live dispatch behind env gates.
|
||||
*/
|
||||
const revolut_business_1 = require("./revolut-business");
|
||||
const wise_platform_1 = require("./wise-platform");
|
||||
const binance_exchange_1 = require("./binance-exchange");
|
||||
function getExternalRailsStatus() {
|
||||
const revolut = (0, revolut_business_1.loadRevolutConfig)();
|
||||
const wise = (0, wise_platform_1.loadWiseConfig)();
|
||||
const binance = (0, binance_exchange_1.loadBinanceConfig)();
|
||||
return [
|
||||
{
|
||||
id: 'REVOLUT',
|
||||
name: 'Revolut Business',
|
||||
configured: Boolean(revolut.accessToken),
|
||||
liveDispatchAllowed: Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
|
||||
baseUrl: revolut.baseUrl,
|
||||
note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID',
|
||||
},
|
||||
{
|
||||
id: 'WISE',
|
||||
name: 'Wise Platform',
|
||||
configured: Boolean(wise.apiToken),
|
||||
liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
|
||||
baseUrl: wise.baseUrl,
|
||||
note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)',
|
||||
},
|
||||
{
|
||||
id: 'BINANCE',
|
||||
name: 'Binance Exchange',
|
||||
configured: Boolean(binance.apiKey && binance.apiSecret),
|
||||
liveDispatchAllowed: Boolean(binance.apiKey && binance.apiSecret) &&
|
||||
process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
|
||||
baseUrl: binance.baseUrl,
|
||||
note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1',
|
||||
},
|
||||
];
|
||||
}
|
||||
async function probeExternalRails() {
|
||||
const status = getExternalRailsStatus();
|
||||
const out = [];
|
||||
for (const s of status) {
|
||||
if (!s.configured) {
|
||||
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (s.id === 'REVOLUT') {
|
||||
const rail = new revolut_business_1.RevolutBusinessRail();
|
||||
const profile = (await rail.getProfile());
|
||||
out.push({
|
||||
...s,
|
||||
probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` },
|
||||
});
|
||||
}
|
||||
else if (s.id === 'WISE') {
|
||||
const rail = new wise_platform_1.WisePlatformRail();
|
||||
const profiles = await rail.listProfiles();
|
||||
out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } });
|
||||
}
|
||||
else {
|
||||
const rail = new binance_exchange_1.BinanceExchangeRail();
|
||||
const ping = await rail.ping();
|
||||
const acct = await rail.accountInfo();
|
||||
out.push({
|
||||
...s,
|
||||
probe: {
|
||||
ok: ping,
|
||||
detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
out.push({
|
||||
...s,
|
||||
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function dispatchExternalBankOrExchange(payload) {
|
||||
const allow = (payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') ||
|
||||
(payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') ||
|
||||
(payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1');
|
||||
if (!allow) {
|
||||
throw new Error(`${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`);
|
||||
}
|
||||
if (payload.rail === 'REVOLUT') {
|
||||
const rail = new revolut_business_1.RevolutBusinessRail();
|
||||
const r = await rail.createPayment({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiaryName: payload.beneficiaryName,
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
reference: payload.remittanceInfo,
|
||||
});
|
||||
return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' };
|
||||
}
|
||||
if (payload.rail === 'WISE') {
|
||||
if (!payload.creditorIban)
|
||||
throw new Error('creditorIban required for Wise');
|
||||
const rail = new wise_platform_1.WisePlatformRail();
|
||||
const r = await rail.payout({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiaryName: payload.beneficiaryName,
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
reference: payload.remittanceInfo,
|
||||
});
|
||||
return { providerId: r.transferId, status: r.status, rail: 'WISE' };
|
||||
}
|
||||
// BINANCE
|
||||
if (!payload.cryptoAddress)
|
||||
throw new Error('cryptoAddress required for Binance withdraw');
|
||||
const rail = new binance_exchange_1.BinanceExchangeRail();
|
||||
const coin = (payload.coin || payload.currency || 'USDT').toUpperCase();
|
||||
const r = await rail.withdraw({
|
||||
coin,
|
||||
amount: payload.amount,
|
||||
address: payload.cryptoAddress,
|
||||
network: payload.cryptoNetwork,
|
||||
name: payload.beneficiaryName,
|
||||
});
|
||||
return { providerId: r.id, status: r.status, rail: 'BINANCE' };
|
||||
}
|
||||
111
services/settlement-middleware/dist/adapters/revolut-business.js
vendored
Normal file
111
services/settlement-middleware/dist/adapters/revolut-business.js
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RevolutBusinessRail = void 0;
|
||||
exports.loadRevolutConfig = loadRevolutConfig;
|
||||
/**
|
||||
* Revolut Business API (production) — accounts / payments.
|
||||
* Docs: https://developer.revolut.com/docs/business/business-api
|
||||
* Auth: Bearer access token (OAuth / Business API token).
|
||||
*/
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
function loadRevolutConfig() {
|
||||
const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim();
|
||||
const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined,
|
||||
enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
class RevolutBusinessRail {
|
||||
config;
|
||||
http;
|
||||
constructor(cfg = loadRevolutConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios_1.default.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
isReady() {
|
||||
return this.config.enabled && Boolean(this.config.accessToken);
|
||||
}
|
||||
async listAccounts() {
|
||||
const { data } = await this.http.get('/accounts');
|
||||
return Array.isArray(data) ? data : data?.accounts ?? [];
|
||||
}
|
||||
async getProfile() {
|
||||
// Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking
|
||||
const accounts = await this.listAccounts();
|
||||
return { accountCount: accounts.length, sample: accounts.slice(0, 2) };
|
||||
}
|
||||
/** Create a payment draft / counterparty payment (production path). */
|
||||
async createPayment(payload) {
|
||||
if (!this.isReady())
|
||||
throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)');
|
||||
const accountId = payload.accountId || this.config.accountId;
|
||||
if (!accountId)
|
||||
throw new Error('REVOLUT_ACCOUNT_ID required for payments');
|
||||
if (!payload.iban)
|
||||
throw new Error('creditor IBAN required for Revolut payout');
|
||||
const body = {
|
||||
request_id: payload.idempotencyKey.slice(0, 40),
|
||||
account_id: accountId,
|
||||
receiver: {
|
||||
counterparty_id: undefined,
|
||||
// Direct IBAN payout (Business API pay shape varies by account region)
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
name: payload.beneficiaryName,
|
||||
},
|
||||
amount: Number(payload.amount),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
reference: payload.reference || payload.idempotencyKey.slice(0, 35),
|
||||
};
|
||||
const { data, status } = await this.http.post('/pay', body, {
|
||||
headers: { 'X-Idempotency-Key': payload.idempotencyKey },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
if (status >= 400) {
|
||||
// Fallback: create counterparty then pay (sandbox/prod variance)
|
||||
const cp = await this.http.post('/counterparty', {
|
||||
company_name: payload.beneficiaryName,
|
||||
bank_country: payload.iban.slice(0, 2),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
}, { validateStatus: () => true });
|
||||
if (cp.status >= 400) {
|
||||
throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`);
|
||||
}
|
||||
const pay2 = await this.http.post('/pay', {
|
||||
request_id: `${payload.idempotencyKey.slice(0, 30)}-2`,
|
||||
account_id: accountId,
|
||||
receiver: { counterparty_id: cp.data?.id },
|
||||
amount: Number(payload.amount),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
reference: payload.reference || 'OMNL',
|
||||
}, { headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } });
|
||||
return {
|
||||
paymentId: String(pay2.data?.id ?? payload.idempotencyKey),
|
||||
status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'),
|
||||
raw: pay2.data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
paymentId: String(data?.id ?? payload.idempotencyKey),
|
||||
status: String(data?.state ?? data?.status ?? 'PENDING'),
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.RevolutBusinessRail = RevolutBusinessRail;
|
||||
142
services/settlement-middleware/dist/adapters/wise-platform.js
vendored
Normal file
142
services/settlement-middleware/dist/adapters/wise-platform.js
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WisePlatformRail = void 0;
|
||||
exports.loadWiseConfig = loadWiseConfig;
|
||||
/**
|
||||
* Wise Platform API (production) — profiles / quotes / transfers.
|
||||
* Docs: https://docs.wise.com/api-docs
|
||||
* Auth: Bearer personal/business API token.
|
||||
*/
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
function loadWiseConfig() {
|
||||
const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim();
|
||||
const baseUrl = (process.env.WISE_API_BASE_URL ||
|
||||
process.env.TRANSFERWISE_API_BASE_URL ||
|
||||
'https://api.transferwise.com').replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
apiToken,
|
||||
profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined,
|
||||
enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
class WisePlatformRail {
|
||||
config;
|
||||
http;
|
||||
constructor(cfg = loadWiseConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios_1.default.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.apiToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
isReady() {
|
||||
return this.config.enabled && Boolean(this.config.apiToken);
|
||||
}
|
||||
async listProfiles() {
|
||||
const { data } = await this.http.get('/v1/profiles');
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
async resolveProfileId() {
|
||||
if (this.config.profileId)
|
||||
return this.config.profileId;
|
||||
const profiles = await this.listProfiles();
|
||||
const first = profiles[0];
|
||||
if (!first?.id)
|
||||
throw new Error('Wise profile not found — set WISE_PROFILE_ID');
|
||||
return String(first.id);
|
||||
}
|
||||
async createQuote(payload) {
|
||||
if (!this.isReady())
|
||||
throw new Error('Wise not configured (WISE_API_TOKEN)');
|
||||
const profileId = await this.resolveProfileId();
|
||||
const body = {
|
||||
profile: Number(profileId) || profileId,
|
||||
sourceCurrency: payload.sourceCurrency.toUpperCase(),
|
||||
targetCurrency: payload.targetCurrency.toUpperCase(),
|
||||
payOut: 'BANK_TRANSFER',
|
||||
};
|
||||
if (payload.sourceAmount)
|
||||
body.sourceAmount = Number(payload.sourceAmount);
|
||||
else if (payload.targetAmount)
|
||||
body.targetAmount = Number(payload.targetAmount);
|
||||
else
|
||||
throw new Error('sourceAmount or targetAmount required');
|
||||
const { data } = await this.http.post('/v3/quotes', body);
|
||||
return {
|
||||
quoteId: String(data?.id ?? data?.uuid ?? ''),
|
||||
rate: data?.rate != null ? Number(data.rate) : undefined,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
async createRecipient(payload) {
|
||||
const profileId = await this.resolveProfileId();
|
||||
const currency = payload.currency.toUpperCase();
|
||||
const type = payload.type || (payload.iban ? 'iban' : 'email');
|
||||
const details = {};
|
||||
if (payload.iban) {
|
||||
details.IBAN = payload.iban;
|
||||
if (payload.bic)
|
||||
details.BIC = payload.bic;
|
||||
}
|
||||
const { data } = await this.http.post('/v1/accounts', {
|
||||
profile: Number(profileId) || profileId,
|
||||
accountHolderName: payload.accountHolderName,
|
||||
currency,
|
||||
type,
|
||||
details,
|
||||
});
|
||||
return { accountId: Number(data?.id), raw: data };
|
||||
}
|
||||
async createTransfer(payload) {
|
||||
if (!this.isReady())
|
||||
throw new Error('Wise not configured (WISE_API_TOKEN)');
|
||||
const { data } = await this.http.post('/v1/transfers', {
|
||||
targetAccount: payload.targetAccountId,
|
||||
quoteUuid: payload.quoteId,
|
||||
customerTransactionId: payload.idempotencyKey.slice(0, 36),
|
||||
details: { reference: payload.reference || 'OMNL Z Online Bank' },
|
||||
});
|
||||
return {
|
||||
transferId: String(data?.id ?? payload.idempotencyKey),
|
||||
status: String(data?.status ?? 'incoming_payment_waiting'),
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
/** Full payout: quote → recipient → transfer (fund separately if required). */
|
||||
async payout(payload) {
|
||||
const source = payload.currency.toUpperCase();
|
||||
const target = (payload.targetCurrency || payload.currency).toUpperCase();
|
||||
const quote = await this.createQuote({
|
||||
sourceCurrency: source,
|
||||
targetCurrency: target,
|
||||
sourceAmount: payload.amount,
|
||||
});
|
||||
const recipient = await this.createRecipient({
|
||||
currency: target,
|
||||
accountHolderName: payload.beneficiaryName,
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
});
|
||||
const transfer = await this.createTransfer({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
quoteId: quote.quoteId,
|
||||
targetAccountId: recipient.accountId,
|
||||
reference: payload.reference,
|
||||
});
|
||||
return {
|
||||
transferId: transfer.transferId,
|
||||
status: transfer.status,
|
||||
quoteId: quote.quoteId,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.WisePlatformRail = WisePlatformRail;
|
||||
@@ -15,6 +15,7 @@ const fiat_lp_to_liquidity_1 = require("../../workflows/fiat-lp-to-liquidity");
|
||||
const btc_l1_settlement_1 = require("../../workflows/btc-l1-settlement");
|
||||
const fineract_1 = require("../../adapters/fineract");
|
||||
const pricing_1 = require("../../adapters/pricing");
|
||||
const external_banks_hub_1 = require("../../adapters/external-banks-hub");
|
||||
const auth_1 = require("../../middleware/auth");
|
||||
async function respondPublicMoneySupply(res, officeId) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
@@ -353,16 +354,54 @@ function createSettlementRouter() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!body.creditorIban) {
|
||||
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
|
||||
if (!body.creditorIban && body.rail !== 'BINANCE') {
|
||||
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' });
|
||||
return;
|
||||
}
|
||||
const externalRail = body.rail === 'RTGS'
|
||||
? 'RTGS'
|
||||
: body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE'
|
||||
? body.rail
|
||||
: 'SWIFT';
|
||||
const record = await (0, transfer_1.processTransfer)({
|
||||
...body,
|
||||
officeId: (0, config_1.settlementOfficeId)(body.officeId),
|
||||
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
rail: externalRail,
|
||||
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
|
||||
});
|
||||
if (record.phase !== 'FAILED' &&
|
||||
(body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE')) {
|
||||
try {
|
||||
const dispatched = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({
|
||||
rail: body.rail,
|
||||
idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId,
|
||||
amount: String(body.amount),
|
||||
currency: String(body.currency || 'USD'),
|
||||
beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary',
|
||||
creditorIban: body.creditorIban,
|
||||
creditorBic: body.creditorBic,
|
||||
remittanceInfo: body.remittanceInfo,
|
||||
cryptoAddress: body.recipientAddress,
|
||||
cryptoNetwork: body.cryptoNetwork,
|
||||
coin: body.tokenSymbol || body.currency,
|
||||
});
|
||||
res.status(200).json({
|
||||
...record,
|
||||
externalRail: dispatched,
|
||||
transferableExternal: true,
|
||||
mode: body.rail === 'BINANCE' ? 'exchange' : 'bank',
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (e) {
|
||||
res.status(422).json({
|
||||
...record,
|
||||
phase: 'FAILED',
|
||||
errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json({
|
||||
...record,
|
||||
transferableExternal: true,
|
||||
@@ -373,6 +412,56 @@ function createSettlementRouter() {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/rails/external', (_req, res) => {
|
||||
res.json({
|
||||
asOf: new Date().toISOString(),
|
||||
rails: (0, external_banks_hub_1.getExternalRailsStatus)(),
|
||||
usage: {
|
||||
transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE',
|
||||
probe: 'GET /rails/external/probe',
|
||||
smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs',
|
||||
},
|
||||
});
|
||||
});
|
||||
router.get('/rails/external/probe', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const rails = await (0, external_banks_hub_1.probeExternalRails)();
|
||||
res.json({ asOf: new Date().toISOString(), rails });
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/rails/external/dispatch', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const rail = String(req.body?.rail || '').toUpperCase();
|
||||
if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) {
|
||||
res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' });
|
||||
return;
|
||||
}
|
||||
const out = await (0, external_banks_hub_1.dispatchExternalBankOrExchange)({
|
||||
rail,
|
||||
idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`),
|
||||
amount: String(req.body?.amount || ''),
|
||||
currency: String(req.body?.currency || 'USD'),
|
||||
beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'),
|
||||
creditorIban: req.body?.creditorIban,
|
||||
creditorBic: req.body?.creditorBic,
|
||||
remittanceInfo: req.body?.remittanceInfo,
|
||||
cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress,
|
||||
cryptoNetwork: req.body?.cryptoNetwork,
|
||||
coin: req.body?.coin || req.body?.tokenSymbol,
|
||||
});
|
||||
res.status(200).json({ ok: true, ...out });
|
||||
}
|
||||
catch (e) {
|
||||
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/tokens/m2', (_req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
|
||||
12
services/settlement-middleware/dist/index.js
vendored
12
services/settlement-middleware/dist/index.js
vendored
@@ -44,4 +44,16 @@ const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
|
||||
if ((0, fs_1.existsSync)(rootEnv))
|
||||
dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
|
||||
const repoRoot = path_1.default.resolve(__dirname, '../../..');
|
||||
for (const rel of [
|
||||
'secrets/revolut/credentials.env',
|
||||
'secrets/wise/credentials.env',
|
||||
'secrets/binance/credentials.env',
|
||||
'config/rails-revolut-wise-binance.env',
|
||||
]) {
|
||||
const p = path_1.default.join(repoRoot, rel);
|
||||
if ((0, fs_1.existsSync)(p))
|
||||
dotenv.config({ path: p, override: false });
|
||||
}
|
||||
(0, server_1.startServer)();
|
||||
|
||||
@@ -12,7 +12,8 @@ const settlement_store_1 = require("../store/settlement-store");
|
||||
const btc_l1_settlement_1 = require("./btc-l1-settlement");
|
||||
/**
|
||||
* INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled.
|
||||
* SWIFT / RTGS → bank rail (IBAN).
|
||||
* SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN).
|
||||
* BINANCE → exchange withdraw (address) after ledger journal.
|
||||
* CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
|
||||
*/
|
||||
async function processTransfer(input) {
|
||||
@@ -20,13 +21,15 @@ async function processTransfer(input) {
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
|
||||
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS' || req.rail === 'REVOLUT' || req.rail === 'WISE';
|
||||
const isExchangeExternal = req.rail === 'BINANCE';
|
||||
/** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
|
||||
const isCryptoExternal = !isBankExternal &&
|
||||
!isExchangeExternal &&
|
||||
Boolean(req.tokenAddress?.startsWith('0x')) &&
|
||||
Boolean(req.recipientAddress?.startsWith('0x')) &&
|
||||
(req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
|
||||
const isExternal = isBankExternal || isCryptoExternal;
|
||||
const isExternal = isBankExternal || isCryptoExternal || isExchangeExternal;
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
@@ -42,20 +45,34 @@ async function processTransfer(input) {
|
||||
currency: req.currency ?? (req.tokenSymbol === 'cBTC' ? 'BTC' : 'USD'),
|
||||
amount: req.amount,
|
||||
creditorIban: req.creditorIban ??
|
||||
(isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'),
|
||||
(isBankExternal
|
||||
? ''
|
||||
: isExchangeExternal
|
||||
? `EXT-BINANCE-${req.recipientAddress || 'withdraw'}`
|
||||
: isCryptoExternal
|
||||
? `EXT-CRYPTO-${req.recipientAddress}`
|
||||
: 'INTERNAL-CHAIN138'),
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo: req.remittanceInfo ??
|
||||
(isCryptoExternal
|
||||
? `EXTERNAL_CRYPTO ${req.tokenSymbol} → ${req.recipientAddress}`
|
||||
: `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`),
|
||||
: isExchangeExternal
|
||||
? `BINANCE_WITHDRAW ${req.currency || req.tokenSymbol}`
|
||||
: `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`),
|
||||
moneyLayers: req.moneyLayers.length
|
||||
? req.moneyLayers
|
||||
: isBankExternal
|
||||
: isBankExternal || isExchangeExternal
|
||||
? ['M2', 'M4']
|
||||
: isCryptoExternal
|
||||
? ['M3', 'M4']
|
||||
: ['M2', 'M3'],
|
||||
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
|
||||
rail: req.rail === 'CHAIN138'
|
||||
? 'CHAIN138'
|
||||
: req.rail === 'INTERNAL'
|
||||
? 'INTERNAL'
|
||||
: req.rail === 'REVOLUT' || req.rail === 'WISE' || req.rail === 'BINANCE'
|
||||
? req.rail
|
||||
: 'SWIFT',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
@@ -69,7 +86,11 @@ async function processTransfer(input) {
|
||||
if (isBankExternal && !req.creditorIban) {
|
||||
throw new Error('creditorIban required for external bank transfer');
|
||||
}
|
||||
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) {
|
||||
if (isExchangeExternal && !req.recipientAddress?.startsWith('0x') && !req.recipientAddress) {
|
||||
throw new Error('recipientAddress required for Binance withdraw');
|
||||
}
|
||||
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') &&
|
||||
!req.recipientAddress?.startsWith('0x')) {
|
||||
throw new Error('recipientAddress (0x) required for on-chain transfer');
|
||||
}
|
||||
advance('VALIDATED');
|
||||
|
||||
126
services/settlement-middleware/src/adapters/binance-exchange.ts
Normal file
126
services/settlement-middleware/src/adapters/binance-exchange.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Binance Spot API (production) — account / withdraw / ticker.
|
||||
* Docs: https://binance-docs.github.io/apidocs/spot/en/
|
||||
* Auth: API key + HMAC SHA256 secret.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export type BinanceConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function loadBinanceConfig(): BinanceConfig {
|
||||
const apiKey = (process.env.BINANCE_API_KEY || '').trim();
|
||||
const apiSecret = (process.env.BINANCE_API_SECRET || '').trim();
|
||||
const baseUrl = (process.env.BINANCE_API_BASE_URL || 'https://api.binance.com').replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
enabled: Boolean(apiKey && apiSecret) && process.env.BINANCE_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
|
||||
export class BinanceExchangeRail {
|
||||
readonly config: BinanceConfig;
|
||||
private readonly http: AxiosInstance;
|
||||
|
||||
constructor(cfg = loadBinanceConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'X-MBX-APIKEY': cfg.apiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.apiKey && this.config.apiSecret);
|
||||
}
|
||||
|
||||
private sign(query: string): string {
|
||||
return crypto.createHmac('sha256', this.config.apiSecret).update(query).digest('hex');
|
||||
}
|
||||
|
||||
private async signedGet(path: string, params: Record<string, string | number> = {}) {
|
||||
const timestamp = Date.now();
|
||||
const qs = new URLSearchParams({
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
timestamp: String(timestamp),
|
||||
}).toString();
|
||||
const signature = this.sign(qs);
|
||||
const { data } = await this.http.get(`${path}?${qs}&signature=${signature}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async signedPost(path: string, params: Record<string, string | number> = {}) {
|
||||
const timestamp = Date.now();
|
||||
const body = new URLSearchParams({
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
timestamp: String(timestamp),
|
||||
});
|
||||
const signature = this.sign(body.toString());
|
||||
body.append('signature', signature);
|
||||
const { data } = await this.http.post(path, body.toString(), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
const { status } = await this.http.get('/api/v3/ping');
|
||||
return status === 200;
|
||||
}
|
||||
|
||||
async accountInfo(): Promise<{ balances: Array<{ asset: string; free: string }>; canTrade: boolean }> {
|
||||
if (!this.isReady()) throw new Error('Binance not configured (BINANCE_API_KEY/SECRET)');
|
||||
const data = await this.signedGet('/api/v3/account');
|
||||
const balances = (data.balances || [])
|
||||
.filter((b: { free: string }) => Number(b.free) > 0)
|
||||
.slice(0, 20)
|
||||
.map((b: { asset: string; free: string }) => ({ asset: b.asset, free: b.free }));
|
||||
return { balances, canTrade: Boolean(data.canTrade) };
|
||||
}
|
||||
|
||||
async tickerPrice(symbol: string): Promise<{ symbol: string; price: string }> {
|
||||
const { data } = await this.http.get('/api/v3/ticker/price', { params: { symbol } });
|
||||
return { symbol: data.symbol, price: data.price };
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw to external address (requires withdrawal permission on API key).
|
||||
* Gate with BINANCE_ALLOW_WITHDRAW=1 in production.
|
||||
*/
|
||||
async withdraw(payload: {
|
||||
coin: string;
|
||||
amount: string;
|
||||
address: string;
|
||||
network?: string;
|
||||
name?: string;
|
||||
}): Promise<{ id: string; status: string; raw: unknown }> {
|
||||
if (!this.isReady()) throw new Error('Binance not configured');
|
||||
if (process.env.BINANCE_ALLOW_WITHDRAW !== '1') {
|
||||
throw new Error('BINANCE_ALLOW_WITHDRAW=1 required for live withdrawals');
|
||||
}
|
||||
const params: Record<string, string> = {
|
||||
coin: payload.coin.toUpperCase(),
|
||||
amount: payload.amount,
|
||||
address: payload.address,
|
||||
};
|
||||
if (payload.network) params.network = payload.network;
|
||||
if (payload.name) params.name = payload.name.slice(0, 30);
|
||||
const data = await this.signedPost('/sapi/v1/capital/withdraw/apply', params);
|
||||
return {
|
||||
id: String(data?.id ?? data?.withdrawOrderId ?? ''),
|
||||
status: 'SUBMITTED',
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* External banks & exchange hub — Revolut Business + Wise + Binance.
|
||||
* Fail-closed until credentials present; live dispatch behind env gates.
|
||||
*/
|
||||
import { RevolutBusinessRail, loadRevolutConfig } from './revolut-business';
|
||||
import { WisePlatformRail, loadWiseConfig } from './wise-platform';
|
||||
import { BinanceExchangeRail, loadBinanceConfig } from './binance-exchange';
|
||||
|
||||
export type ExternalRailId = 'REVOLUT' | 'WISE' | 'BINANCE';
|
||||
|
||||
export type RailStatus = {
|
||||
id: ExternalRailId;
|
||||
name: string;
|
||||
configured: boolean;
|
||||
liveDispatchAllowed: boolean;
|
||||
baseUrl: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function getExternalRailsStatus(): RailStatus[] {
|
||||
const revolut = loadRevolutConfig();
|
||||
const wise = loadWiseConfig();
|
||||
const binance = loadBinanceConfig();
|
||||
return [
|
||||
{
|
||||
id: 'REVOLUT',
|
||||
name: 'Revolut Business',
|
||||
configured: Boolean(revolut.accessToken),
|
||||
liveDispatchAllowed:
|
||||
Boolean(revolut.accessToken) && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1',
|
||||
baseUrl: revolut.baseUrl,
|
||||
note: 'Business API Bearer token · set REVOLUT_ACCESS_TOKEN + REVOLUT_ACCOUNT_ID',
|
||||
},
|
||||
{
|
||||
id: 'WISE',
|
||||
name: 'Wise Platform',
|
||||
configured: Boolean(wise.apiToken),
|
||||
liveDispatchAllowed: Boolean(wise.apiToken) && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1',
|
||||
baseUrl: wise.baseUrl,
|
||||
note: 'API token · set WISE_API_TOKEN (+ WISE_PROFILE_ID recommended)',
|
||||
},
|
||||
{
|
||||
id: 'BINANCE',
|
||||
name: 'Binance Exchange',
|
||||
configured: Boolean(binance.apiKey && binance.apiSecret),
|
||||
liveDispatchAllowed:
|
||||
Boolean(binance.apiKey && binance.apiSecret) &&
|
||||
process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1',
|
||||
baseUrl: binance.baseUrl,
|
||||
note: 'HMAC API key · withdrawals need BINANCE_ALLOW_WITHDRAW=1',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function probeExternalRails(): Promise<
|
||||
Array<RailStatus & { probe?: { ok: boolean; detail: string } }>
|
||||
> {
|
||||
const status = getExternalRailsStatus();
|
||||
const out: Array<RailStatus & { probe?: { ok: boolean; detail: string } }> = [];
|
||||
|
||||
for (const s of status) {
|
||||
if (!s.configured) {
|
||||
out.push({ ...s, probe: { ok: false, detail: 'credentials missing' } });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (s.id === 'REVOLUT') {
|
||||
const rail = new RevolutBusinessRail();
|
||||
const profile = (await rail.getProfile()) as { accountCount?: number };
|
||||
out.push({
|
||||
...s,
|
||||
probe: { ok: true, detail: `accounts=${profile.accountCount ?? '?'}` },
|
||||
});
|
||||
} else if (s.id === 'WISE') {
|
||||
const rail = new WisePlatformRail();
|
||||
const profiles = await rail.listProfiles();
|
||||
out.push({ ...s, probe: { ok: true, detail: `profiles=${profiles.length}` } });
|
||||
} else {
|
||||
const rail = new BinanceExchangeRail();
|
||||
const ping = await rail.ping();
|
||||
const acct = await rail.accountInfo();
|
||||
out.push({
|
||||
...s,
|
||||
probe: {
|
||||
ok: ping,
|
||||
detail: `ping=${ping} balances=${acct.balances.length} canTrade=${acct.canTrade}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
out.push({
|
||||
...s,
|
||||
probe: { ok: false, detail: e instanceof Error ? e.message.slice(0, 200) : String(e) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function dispatchExternalBankOrExchange(payload: {
|
||||
rail: ExternalRailId;
|
||||
idempotencyKey: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
beneficiaryName: string;
|
||||
creditorIban?: string;
|
||||
creditorBic?: string;
|
||||
remittanceInfo?: string;
|
||||
/** Binance withdraw */
|
||||
cryptoAddress?: string;
|
||||
cryptoNetwork?: string;
|
||||
coin?: string;
|
||||
}): Promise<{ providerId: string; status: string; rail: ExternalRailId }> {
|
||||
const allow =
|
||||
(payload.rail === 'REVOLUT' && process.env.SETTLEMENT_ALLOW_REVOLUT_PRODUCTION === '1') ||
|
||||
(payload.rail === 'WISE' && process.env.SETTLEMENT_ALLOW_WISE_PRODUCTION === '1') ||
|
||||
(payload.rail === 'BINANCE' && process.env.SETTLEMENT_ALLOW_BINANCE_PRODUCTION === '1');
|
||||
if (!allow) {
|
||||
throw new Error(
|
||||
`${payload.rail} live dispatch blocked — set SETTLEMENT_ALLOW_${payload.rail}_PRODUCTION=1`,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.rail === 'REVOLUT') {
|
||||
const rail = new RevolutBusinessRail();
|
||||
const r = await rail.createPayment({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiaryName: payload.beneficiaryName,
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
reference: payload.remittanceInfo,
|
||||
});
|
||||
return { providerId: r.paymentId, status: r.status, rail: 'REVOLUT' };
|
||||
}
|
||||
|
||||
if (payload.rail === 'WISE') {
|
||||
if (!payload.creditorIban) throw new Error('creditorIban required for Wise');
|
||||
const rail = new WisePlatformRail();
|
||||
const r = await rail.payout({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
beneficiaryName: payload.beneficiaryName,
|
||||
iban: payload.creditorIban,
|
||||
bic: payload.creditorBic,
|
||||
reference: payload.remittanceInfo,
|
||||
});
|
||||
return { providerId: r.transferId, status: r.status, rail: 'WISE' };
|
||||
}
|
||||
|
||||
// BINANCE
|
||||
if (!payload.cryptoAddress) throw new Error('cryptoAddress required for Binance withdraw');
|
||||
const rail = new BinanceExchangeRail();
|
||||
const coin = (payload.coin || payload.currency || 'USDT').toUpperCase();
|
||||
const r = await rail.withdraw({
|
||||
coin,
|
||||
amount: payload.amount,
|
||||
address: payload.cryptoAddress,
|
||||
network: payload.cryptoNetwork,
|
||||
name: payload.beneficiaryName,
|
||||
});
|
||||
return { providerId: r.id, status: r.status, rail: 'BINANCE' };
|
||||
}
|
||||
133
services/settlement-middleware/src/adapters/revolut-business.ts
Normal file
133
services/settlement-middleware/src/adapters/revolut-business.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Revolut Business API (production) — accounts / payments.
|
||||
* Docs: https://developer.revolut.com/docs/business/business-api
|
||||
* Auth: Bearer access token (OAuth / Business API token).
|
||||
*/
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export type RevolutConfig = {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
accountId?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function loadRevolutConfig(): RevolutConfig {
|
||||
const accessToken = (process.env.REVOLUT_ACCESS_TOKEN || process.env.REVOLUT_API_TOKEN || '').trim();
|
||||
const baseUrl = (process.env.REVOLUT_API_BASE_URL || 'https://b2b.revolut.com/api/1.0').replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
accountId: process.env.REVOLUT_ACCOUNT_ID?.trim() || undefined,
|
||||
enabled: Boolean(accessToken) && process.env.REVOLUT_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
|
||||
export class RevolutBusinessRail {
|
||||
readonly config: RevolutConfig;
|
||||
private readonly http: AxiosInstance;
|
||||
|
||||
constructor(cfg = loadRevolutConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.accessToken);
|
||||
}
|
||||
|
||||
async listAccounts(): Promise<unknown[]> {
|
||||
const { data } = await this.http.get('/accounts');
|
||||
return Array.isArray(data) ? data : data?.accounts ?? [];
|
||||
}
|
||||
|
||||
async getProfile(): Promise<unknown> {
|
||||
// Business API: /accounts is the usual smoke; some tenants expose /me via Open Banking
|
||||
const accounts = await this.listAccounts();
|
||||
return { accountCount: accounts.length, sample: accounts.slice(0, 2) };
|
||||
}
|
||||
|
||||
/** Create a payment draft / counterparty payment (production path). */
|
||||
async createPayment(payload: {
|
||||
idempotencyKey: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
beneficiaryName: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
accountId?: string;
|
||||
reference?: string;
|
||||
}): Promise<{ paymentId: string; status: string; raw: unknown }> {
|
||||
if (!this.isReady()) throw new Error('Revolut Business not configured (REVOLUT_ACCESS_TOKEN)');
|
||||
const accountId = payload.accountId || this.config.accountId;
|
||||
if (!accountId) throw new Error('REVOLUT_ACCOUNT_ID required for payments');
|
||||
if (!payload.iban) throw new Error('creditor IBAN required for Revolut payout');
|
||||
|
||||
const body = {
|
||||
request_id: payload.idempotencyKey.slice(0, 40),
|
||||
account_id: accountId,
|
||||
receiver: {
|
||||
counterparty_id: undefined as string | undefined,
|
||||
// Direct IBAN payout (Business API pay shape varies by account region)
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
name: payload.beneficiaryName,
|
||||
},
|
||||
amount: Number(payload.amount),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
reference: payload.reference || payload.idempotencyKey.slice(0, 35),
|
||||
};
|
||||
|
||||
const { data, status } = await this.http.post('/pay', body, {
|
||||
headers: { 'X-Idempotency-Key': payload.idempotencyKey },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
if (status >= 400) {
|
||||
// Fallback: create counterparty then pay (sandbox/prod variance)
|
||||
const cp = await this.http.post(
|
||||
'/counterparty',
|
||||
{
|
||||
company_name: payload.beneficiaryName,
|
||||
bank_country: payload.iban.slice(0, 2),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
},
|
||||
{ validateStatus: () => true },
|
||||
);
|
||||
if (cp.status >= 400) {
|
||||
throw new Error(`Revolut pay failed ${status}: ${JSON.stringify(data).slice(0, 300)}`);
|
||||
}
|
||||
const pay2 = await this.http.post(
|
||||
'/pay',
|
||||
{
|
||||
request_id: `${payload.idempotencyKey.slice(0, 30)}-2`,
|
||||
account_id: accountId,
|
||||
receiver: { counterparty_id: cp.data?.id },
|
||||
amount: Number(payload.amount),
|
||||
currency: payload.currency.toUpperCase(),
|
||||
reference: payload.reference || 'OMNL',
|
||||
},
|
||||
{ headers: { 'X-Idempotency-Key': `${payload.idempotencyKey}-2` } },
|
||||
);
|
||||
return {
|
||||
paymentId: String(pay2.data?.id ?? payload.idempotencyKey),
|
||||
status: String(pay2.data?.state ?? pay2.data?.status ?? 'PENDING'),
|
||||
raw: pay2.data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
paymentId: String(data?.id ?? payload.idempotencyKey),
|
||||
status: String(data?.state ?? data?.status ?? 'PENDING'),
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
171
services/settlement-middleware/src/adapters/wise-platform.ts
Normal file
171
services/settlement-middleware/src/adapters/wise-platform.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Wise Platform API (production) — profiles / quotes / transfers.
|
||||
* Docs: https://docs.wise.com/api-docs
|
||||
* Auth: Bearer personal/business API token.
|
||||
*/
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export type WiseConfig = {
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
profileId?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function loadWiseConfig(): WiseConfig {
|
||||
const apiToken = (process.env.WISE_API_TOKEN || process.env.TRANSFERWISE_API_TOKEN || '').trim();
|
||||
const baseUrl = (
|
||||
process.env.WISE_API_BASE_URL ||
|
||||
process.env.TRANSFERWISE_API_BASE_URL ||
|
||||
'https://api.transferwise.com'
|
||||
).replace(/\/$/, '');
|
||||
return {
|
||||
baseUrl,
|
||||
apiToken,
|
||||
profileId: (process.env.WISE_PROFILE_ID || process.env.TRANSFERWISE_PROFILE_ID || '').trim() || undefined,
|
||||
enabled: Boolean(apiToken) && process.env.WISE_ENABLED !== '0',
|
||||
};
|
||||
}
|
||||
|
||||
export class WisePlatformRail {
|
||||
readonly config: WiseConfig;
|
||||
private readonly http: AxiosInstance;
|
||||
|
||||
constructor(cfg = loadWiseConfig()) {
|
||||
this.config = cfg;
|
||||
this.http = axios.create({
|
||||
baseURL: cfg.baseUrl,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.apiToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.config.enabled && Boolean(this.config.apiToken);
|
||||
}
|
||||
|
||||
async listProfiles(): Promise<unknown[]> {
|
||||
const { data } = await this.http.get('/v1/profiles');
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async resolveProfileId(): Promise<string> {
|
||||
if (this.config.profileId) return this.config.profileId;
|
||||
const profiles = await this.listProfiles();
|
||||
const first = profiles[0] as { id?: number | string } | undefined;
|
||||
if (!first?.id) throw new Error('Wise profile not found — set WISE_PROFILE_ID');
|
||||
return String(first.id);
|
||||
}
|
||||
|
||||
async createQuote(payload: {
|
||||
sourceCurrency: string;
|
||||
targetCurrency: string;
|
||||
sourceAmount?: string;
|
||||
targetAmount?: string;
|
||||
}): Promise<{ quoteId: string; rate?: number; raw: unknown }> {
|
||||
if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)');
|
||||
const profileId = await this.resolveProfileId();
|
||||
const body: Record<string, unknown> = {
|
||||
profile: Number(profileId) || profileId,
|
||||
sourceCurrency: payload.sourceCurrency.toUpperCase(),
|
||||
targetCurrency: payload.targetCurrency.toUpperCase(),
|
||||
payOut: 'BANK_TRANSFER',
|
||||
};
|
||||
if (payload.sourceAmount) body.sourceAmount = Number(payload.sourceAmount);
|
||||
else if (payload.targetAmount) body.targetAmount = Number(payload.targetAmount);
|
||||
else throw new Error('sourceAmount or targetAmount required');
|
||||
|
||||
const { data } = await this.http.post('/v3/quotes', body);
|
||||
return {
|
||||
quoteId: String(data?.id ?? data?.uuid ?? ''),
|
||||
rate: data?.rate != null ? Number(data.rate) : undefined,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
async createRecipient(payload: {
|
||||
currency: string;
|
||||
accountHolderName: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
type?: string;
|
||||
}): Promise<{ accountId: number; raw: unknown }> {
|
||||
const profileId = await this.resolveProfileId();
|
||||
const currency = payload.currency.toUpperCase();
|
||||
const type = payload.type || (payload.iban ? 'iban' : 'email');
|
||||
const details: Record<string, unknown> = {};
|
||||
if (payload.iban) {
|
||||
details.IBAN = payload.iban;
|
||||
if (payload.bic) details.BIC = payload.bic;
|
||||
}
|
||||
const { data } = await this.http.post('/v1/accounts', {
|
||||
profile: Number(profileId) || profileId,
|
||||
accountHolderName: payload.accountHolderName,
|
||||
currency,
|
||||
type,
|
||||
details,
|
||||
});
|
||||
return { accountId: Number(data?.id), raw: data };
|
||||
}
|
||||
|
||||
async createTransfer(payload: {
|
||||
idempotencyKey: string;
|
||||
quoteId: string;
|
||||
targetAccountId: number;
|
||||
reference?: string;
|
||||
}): Promise<{ transferId: string; status: string; raw: unknown }> {
|
||||
if (!this.isReady()) throw new Error('Wise not configured (WISE_API_TOKEN)');
|
||||
const { data } = await this.http.post('/v1/transfers', {
|
||||
targetAccount: payload.targetAccountId,
|
||||
quoteUuid: payload.quoteId,
|
||||
customerTransactionId: payload.idempotencyKey.slice(0, 36),
|
||||
details: { reference: payload.reference || 'OMNL Z Online Bank' },
|
||||
});
|
||||
return {
|
||||
transferId: String(data?.id ?? payload.idempotencyKey),
|
||||
status: String(data?.status ?? 'incoming_payment_waiting'),
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full payout: quote → recipient → transfer (fund separately if required). */
|
||||
async payout(payload: {
|
||||
idempotencyKey: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
targetCurrency?: string;
|
||||
beneficiaryName: string;
|
||||
iban: string;
|
||||
bic?: string;
|
||||
reference?: string;
|
||||
}): Promise<{ transferId: string; status: string; quoteId: string }> {
|
||||
const source = payload.currency.toUpperCase();
|
||||
const target = (payload.targetCurrency || payload.currency).toUpperCase();
|
||||
const quote = await this.createQuote({
|
||||
sourceCurrency: source,
|
||||
targetCurrency: target,
|
||||
sourceAmount: payload.amount,
|
||||
});
|
||||
const recipient = await this.createRecipient({
|
||||
currency: target,
|
||||
accountHolderName: payload.beneficiaryName,
|
||||
iban: payload.iban,
|
||||
bic: payload.bic,
|
||||
});
|
||||
const transfer = await this.createTransfer({
|
||||
idempotencyKey: payload.idempotencyKey,
|
||||
quoteId: quote.quoteId,
|
||||
targetAccountId: recipient.accountId,
|
||||
reference: payload.reference,
|
||||
});
|
||||
return {
|
||||
transferId: transfer.transferId,
|
||||
status: transfer.status,
|
||||
quoteId: quote.quoteId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
} from '../../workflows/btc-l1-settlement';
|
||||
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
|
||||
import { resolveLiveBtcUsd } from '../../adapters/pricing';
|
||||
import {
|
||||
dispatchExternalBankOrExchange,
|
||||
getExternalRailsStatus,
|
||||
probeExternalRails,
|
||||
type ExternalRailId,
|
||||
} from '../../adapters/external-banks-hub';
|
||||
import { requireApiKey } from '../../middleware/auth';
|
||||
|
||||
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
|
||||
@@ -368,16 +374,58 @@ export function createSettlementRouter(): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.creditorIban) {
|
||||
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto)' });
|
||||
if (!body.creditorIban && body.rail !== 'BINANCE') {
|
||||
res.status(400).json({ error: 'creditorIban required for external bank transfer (or use mode=crypto / rail=BINANCE)' });
|
||||
return;
|
||||
}
|
||||
const externalRail =
|
||||
body.rail === 'RTGS'
|
||||
? 'RTGS'
|
||||
: body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE'
|
||||
? body.rail
|
||||
: 'SWIFT';
|
||||
const record = await processTransfer({
|
||||
...body,
|
||||
officeId: settlementOfficeId(body.officeId),
|
||||
rail: body.rail === 'RTGS' ? 'RTGS' : 'SWIFT',
|
||||
rail: externalRail,
|
||||
moneyLayers: body.moneyLayers ?? ['M2', 'M4'],
|
||||
});
|
||||
|
||||
if (
|
||||
record.phase !== 'FAILED' &&
|
||||
(body.rail === 'REVOLUT' || body.rail === 'WISE' || body.rail === 'BINANCE')
|
||||
) {
|
||||
try {
|
||||
const dispatched = await dispatchExternalBankOrExchange({
|
||||
rail: body.rail as ExternalRailId,
|
||||
idempotencyKey: body.idempotencyKey || record.idempotencyKey || record.settlementId,
|
||||
amount: String(body.amount),
|
||||
currency: String(body.currency || 'USD'),
|
||||
beneficiaryName: body.beneficiaryName || body.remittanceInfo || 'OMNL beneficiary',
|
||||
creditorIban: body.creditorIban,
|
||||
creditorBic: body.creditorBic,
|
||||
remittanceInfo: body.remittanceInfo,
|
||||
cryptoAddress: body.recipientAddress,
|
||||
cryptoNetwork: (body as { cryptoNetwork?: string }).cryptoNetwork,
|
||||
coin: body.tokenSymbol || body.currency,
|
||||
});
|
||||
res.status(200).json({
|
||||
...record,
|
||||
externalRail: dispatched,
|
||||
transferableExternal: true,
|
||||
mode: body.rail === 'BINANCE' ? 'exchange' : 'bank',
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
res.status(422).json({
|
||||
...record,
|
||||
phase: 'FAILED',
|
||||
errors: [...(record.errors || []), e instanceof Error ? e.message : String(e)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(record.phase === 'FAILED' ? 422 : 200).json({
|
||||
...record,
|
||||
transferableExternal: true,
|
||||
@@ -388,6 +436,55 @@ export function createSettlementRouter(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/rails/external', (_req, res) => {
|
||||
res.json({
|
||||
asOf: new Date().toISOString(),
|
||||
rails: getExternalRailsStatus(),
|
||||
usage: {
|
||||
transfer: 'POST /transfer/external with rail=REVOLUT|WISE|BINANCE',
|
||||
probe: 'GET /rails/external/probe',
|
||||
smoke: 'node scripts/rails/smoke-revolut-wise-binance.mjs',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/rails/external/probe', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const rails = await probeExternalRails();
|
||||
res.json({ asOf: new Date().toISOString(), rails });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/rails/external/dispatch', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const rail = String(req.body?.rail || '').toUpperCase() as ExternalRailId;
|
||||
if (!['REVOLUT', 'WISE', 'BINANCE'].includes(rail)) {
|
||||
res.status(400).json({ error: 'rail must be REVOLUT, WISE, or BINANCE' });
|
||||
return;
|
||||
}
|
||||
const out = await dispatchExternalBankOrExchange({
|
||||
rail,
|
||||
idempotencyKey: String(req.body?.idempotencyKey || `rail-${Date.now()}`),
|
||||
amount: String(req.body?.amount || ''),
|
||||
currency: String(req.body?.currency || 'USD'),
|
||||
beneficiaryName: String(req.body?.beneficiaryName || 'OMNL'),
|
||||
creditorIban: req.body?.creditorIban,
|
||||
creditorBic: req.body?.creditorBic,
|
||||
remittanceInfo: req.body?.remittanceInfo,
|
||||
cryptoAddress: req.body?.cryptoAddress || req.body?.recipientAddress,
|
||||
cryptoNetwork: req.body?.cryptoNetwork,
|
||||
coin: req.body?.coin || req.body?.tokenSymbol,
|
||||
});
|
||||
res.status(200).json({ ok: true, ...out });
|
||||
} catch (e) {
|
||||
res.status(422).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/m2', (_req, res) => {
|
||||
try {
|
||||
const fs = require('fs') as typeof import('fs');
|
||||
|
||||
@@ -7,4 +7,16 @@ const rootEnv = path.resolve(__dirname, '../../../.env');
|
||||
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
|
||||
dotenv.config();
|
||||
|
||||
/** Production rail secrets (gitignored) — Revolut / Wise / Binance */
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
for (const rel of [
|
||||
'secrets/revolut/credentials.env',
|
||||
'secrets/wise/credentials.env',
|
||||
'secrets/binance/credentials.env',
|
||||
'config/rails-revolut-wise-binance.env',
|
||||
]) {
|
||||
const p = path.join(repoRoot, rel);
|
||||
if (existsSync(p)) dotenv.config({ path: p, override: false });
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
@@ -10,7 +10,8 @@ import { allowChainMintExecute } from './btc-l1-settlement';
|
||||
|
||||
/**
|
||||
* INTERNAL / CHAIN138 → on-chain ERC-20 when execute enabled.
|
||||
* SWIFT / RTGS → bank rail (IBAN).
|
||||
* SWIFT / RTGS / REVOLUT / WISE → bank rail (IBAN).
|
||||
* BINANCE → exchange withdraw (address) after ledger journal.
|
||||
* CHAIN138 with externalRecipient → treated as external crypto egress (still on-chain).
|
||||
*/
|
||||
export async function processTransfer(input: TransferRequest): Promise<SettlementRecord> {
|
||||
@@ -18,14 +19,17 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const isBankExternal = req.rail === 'SWIFT' || req.rail === 'RTGS';
|
||||
const isBankExternal =
|
||||
req.rail === 'SWIFT' || req.rail === 'RTGS' || req.rail === 'REVOLUT' || req.rail === 'WISE';
|
||||
const isExchangeExternal = req.rail === 'BINANCE';
|
||||
/** Crypto leaving OMNL custody to any 0x wallet (external transferable). */
|
||||
const isCryptoExternal =
|
||||
!isBankExternal &&
|
||||
!isExchangeExternal &&
|
||||
Boolean(req.tokenAddress?.startsWith('0x')) &&
|
||||
Boolean(req.recipientAddress?.startsWith('0x')) &&
|
||||
(req.rail === 'CHAIN138' || req.remittanceInfo?.includes('EXTERNAL_CRYPTO'));
|
||||
const isExternal = isBankExternal || isCryptoExternal;
|
||||
const isExternal = isBankExternal || isCryptoExternal || isExchangeExternal;
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
@@ -43,21 +47,36 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
amount: req.amount,
|
||||
creditorIban:
|
||||
req.creditorIban ??
|
||||
(isBankExternal ? '' : isCryptoExternal ? `EXT-CRYPTO-${req.recipientAddress}` : 'INTERNAL-CHAIN138'),
|
||||
(isBankExternal
|
||||
? ''
|
||||
: isExchangeExternal
|
||||
? `EXT-BINANCE-${req.recipientAddress || 'withdraw'}`
|
||||
: isCryptoExternal
|
||||
? `EXT-CRYPTO-${req.recipientAddress}`
|
||||
: 'INTERNAL-CHAIN138'),
|
||||
beneficiaryName: req.beneficiaryName ?? req.recipientAddress,
|
||||
remittanceInfo:
|
||||
req.remittanceInfo ??
|
||||
(isCryptoExternal
|
||||
? `EXTERNAL_CRYPTO ${req.tokenSymbol} → ${req.recipientAddress}`
|
||||
: `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`),
|
||||
: isExchangeExternal
|
||||
? `BINANCE_WITHDRAW ${req.currency || req.tokenSymbol}`
|
||||
: `Transfer ${req.tokenSymbol} → ${req.recipientAddress}`),
|
||||
moneyLayers: req.moneyLayers.length
|
||||
? req.moneyLayers
|
||||
: isBankExternal
|
||||
: isBankExternal || isExchangeExternal
|
||||
? ['M2', 'M4']
|
||||
: isCryptoExternal
|
||||
? ['M3', 'M4']
|
||||
: ['M2', 'M3'],
|
||||
rail: req.rail === 'CHAIN138' ? 'CHAIN138' : req.rail === 'INTERNAL' ? 'INTERNAL' : 'SWIFT',
|
||||
rail:
|
||||
req.rail === 'CHAIN138'
|
||||
? 'CHAIN138'
|
||||
: req.rail === 'INTERNAL'
|
||||
? 'INTERNAL'
|
||||
: req.rail === 'REVOLUT' || req.rail === 'WISE' || req.rail === 'BINANCE'
|
||||
? req.rail
|
||||
: 'SWIFT',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
@@ -73,7 +92,13 @@ export async function processTransfer(input: TransferRequest): Promise<Settlemen
|
||||
if (isBankExternal && !req.creditorIban) {
|
||||
throw new Error('creditorIban required for external bank transfer');
|
||||
}
|
||||
if ((isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') && !req.recipientAddress?.startsWith('0x')) {
|
||||
if (isExchangeExternal && !req.recipientAddress?.startsWith('0x') && !req.recipientAddress) {
|
||||
throw new Error('recipientAddress required for Binance withdraw');
|
||||
}
|
||||
if (
|
||||
(isCryptoExternal || req.rail === 'INTERNAL' || req.rail === 'CHAIN138') &&
|
||||
!req.recipientAddress?.startsWith('0x')
|
||||
) {
|
||||
throw new Error('recipientAddress (0x) required for on-chain transfer');
|
||||
}
|
||||
advance('VALIDATED');
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OMNL — Revolut · Wise · Binance rails</title>
|
||||
<style>
|
||||
:root { --bg:#071018; --fg:#e8eef5; --muted:#8fa3b8; --ok:#3dd68c; --warn:#f0b429; --bad:#f07178; --line:#1a2836; --accent:#c9a227; }
|
||||
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
||||
main { max-width:900px; margin:0 auto; padding:2rem 1.25rem; }
|
||||
h1 { color:var(--accent); margin:0 0 .5rem; }
|
||||
.badge { display:inline-block; margin:.2rem .35rem .2rem 0; padding:.25rem .65rem; border:1px solid var(--ok); color:var(--ok); border-radius:999px; font-size:.72rem; font-weight:700; }
|
||||
.sub { color:var(--muted); margin:0 0 1.5rem; line-height:1.5; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th,td { text-align:left; padding:.7rem .45rem; border-bottom:1px solid var(--line); vertical-align:top; font-size:.9rem; }
|
||||
th { color:var(--muted); font-size:.7rem; text-transform:uppercase; }
|
||||
.ok { color:var(--ok); font-weight:600; }
|
||||
.warn { color:var(--warn); }
|
||||
.bad { color:var(--bad); }
|
||||
code { font-size:.78rem; color:#c5d4e3; }
|
||||
a { color:var(--accent); }
|
||||
.note { margin-top:1.25rem; color:var(--muted); font-size:.85rem; line-height:1.5; }
|
||||
</style></head>
|
||||
<body><main>
|
||||
<div>
|
||||
<span class="badge">REVOLUT BUSINESS</span>
|
||||
<span class="badge">WISE PLATFORM</span>
|
||||
<span class="badge">BINANCE SPOT</span>
|
||||
</div>
|
||||
<h1>External production rails</h1>
|
||||
<p class="sub">Live adapters for Revolut Business, Wise, and Binance. Credentials load from env / <code>secrets/*</code>. Live payouts require explicit <code>SETTLEMENT_ALLOW_*_PRODUCTION=1</code> gates.</p>
|
||||
<table>
|
||||
<thead><tr><th>Rail</th><th>Configured</th><th>Live gate</th><th>Base URL</th><th>Note</th></tr></thead>
|
||||
<tbody id="tbody"><tr><td colspan="5">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
<p class="note">
|
||||
Settlement: <code>GET /api/v1/settlement/rails/external</code><br>
|
||||
Transfer: <code>POST /transfer/external</code> with <code>rail=REVOLUT|WISE|BINANCE</code><br>
|
||||
Smoke: <code>node scripts/rails/smoke-revolut-wise-binance.mjs</code><br>
|
||||
Cards: <a href="/zbank/cards">/zbank/cards</a> · Random pack includes Revolut / Wise / Binance themes
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
async function load() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
// Prefer settlement middleware if proxied; else show static template from known env pattern
|
||||
const urls = [
|
||||
'/settlement/rails/external',
|
||||
'/api/v1/settlement/rails/external',
|
||||
'http://127.0.0.1:3011/api/v1/settlement/rails/external',
|
||||
];
|
||||
let data = null;
|
||||
for (const u of urls) {
|
||||
try {
|
||||
const r = await fetch(u);
|
||||
if (r.ok) { data = await r.json(); break; }
|
||||
} catch {}
|
||||
}
|
||||
if (!data?.rails) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="warn">Settlement rails endpoint unreachable — start settlement-middleware. Adapters are shipped; add credentials to secrets/revolut|wise|binance/credentials.env</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = data.rails.map(r => {
|
||||
const cfg = r.configured ? '<span class="ok">YES</span>' : '<span class="bad">NO</span>';
|
||||
const live = r.liveDispatchAllowed ? '<span class="ok">OPEN</span>' : '<span class="warn">CLOSED</span>';
|
||||
return `<tr><td><strong>${r.name}</strong><br><code>${r.id}</code></td><td>${cfg}</td><td>${live}</td><td><code>${r.baseUrl}</code></td><td>${r.note}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -65,6 +65,22 @@
|
||||
}
|
||||
.face.front.eur { background: linear-gradient(135deg, #1b4332 0%, #2d6a4f 50%, #081c15 100%); }
|
||||
.face.front.gbp { background: linear-gradient(135deg, #4a1942 0%, #7b2d5e 45%, #1a0a16 100%); }
|
||||
.face.front.theme-nova { background: linear-gradient(135deg, #0ea5e9 0%, #0369a1 50%, #0c4a6e 100%); }
|
||||
.face.front.theme-anaka { background: linear-gradient(135deg, #a855f7 0%, #6b21a8 50%, #3b0764 100%); }
|
||||
.face.front.theme-onex { background: linear-gradient(135deg, #f97316 0%, #c2410c 50%, #7c2d12 100%); }
|
||||
.face.front.theme-omnl { background: linear-gradient(135deg, #d4af37 0%, #8a6d1a 55%, #3d3008 100%); }
|
||||
.face.front.theme-hermes { background: linear-gradient(135deg, #14b8a6 0%, #0f766e 50%, #134e4a 100%); }
|
||||
.face.front.theme-tsunami { background: linear-gradient(135deg, #38bdf8 0%, #0284c7 45%, #082f49 100%); }
|
||||
.face.front.theme-hybx { background: linear-gradient(135deg, #ef4444 0%, #991b1b 50%, #450a0a 100%); }
|
||||
.face.front.theme-zbank { background: linear-gradient(135deg, #1a1f71 0%, #0b3d91 45%, #162447 100%); }
|
||||
.face.front.theme-revolut { background: linear-gradient(135deg, #191c1f 0%, #2d3436 45%, #636e72 100%); }
|
||||
.face.front.theme-wise { background: linear-gradient(135deg, #9fe870 0%, #2e7d32 50%, #1b5e20 100%); color:#0a0a0a; }
|
||||
.face.front.theme-binance { background: linear-gradient(135deg, #f0b90b 0%, #c99400 45%, #3d2e00 100%); }
|
||||
.ex-name {
|
||||
position: absolute; top: 1rem; left: 1.15rem;
|
||||
font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
|
||||
opacity: .95; max-width: 55%;
|
||||
}
|
||||
.face.back {
|
||||
transform: rotateY(180deg);
|
||||
background: linear-gradient(160deg, #1c2430 0%, #121820 55%, #0a0e14 100%);
|
||||
@@ -202,10 +218,10 @@
|
||||
<div>
|
||||
<span class="badge">FRONT + BACK</span>
|
||||
<span class="badge">ONLINE VISA</span>
|
||||
<span class="badge">CVV / MAGSTRIPE</span>
|
||||
<span class="badge">RANDOM EXCHANGES</span>
|
||||
</div>
|
||||
<h1>Online Cards — Front & Back</h1>
|
||||
<p class="sub">Hover or click a card to flip. Back shows magnetic stripe, signature strip, and CVV. Create front + back for each IBAN wallet.</p>
|
||||
<p class="sub">Hover or click to flip. Create Z Online cards or mint a random pack of exchange-branded Visas (Nova, Anaka, OneX, HYBX, Tsunami…).</p>
|
||||
|
||||
<div class="grid" id="faces"></div>
|
||||
|
||||
@@ -222,14 +238,18 @@
|
||||
<button id="issue" type="button">Create Front + Back</button>
|
||||
<button id="issueAll" type="button" class="secondary">Create All 3 CCY</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="issueRandom" type="button">Random Exchange Cards ×4</button>
|
||||
<button id="issueRandom8" type="button" class="secondary">All Exchanges Pack</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>
|
||||
<thead><tr><th>Exchange</th><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="7">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
<p style="margin-top:1rem;color:var(--muted);font-size:.85rem">
|
||||
<a href="/zbank/cards">/zbank/cards</a> ·
|
||||
@@ -243,6 +263,8 @@ const tbody = document.getElementById('tbody');
|
||||
const result = document.getElementById('result');
|
||||
const issueBtn = document.getElementById('issue');
|
||||
const issueAllBtn = document.getElementById('issueAll');
|
||||
const issueRandomBtn = document.getElementById('issueRandom');
|
||||
const issueRandom8Btn = document.getElementById('issueRandom8');
|
||||
const sessionSecrets = {}; // panMasked -> { pan, cvv }
|
||||
|
||||
function cvvDisplay(c) {
|
||||
@@ -259,11 +281,14 @@ function cvvDisplay(c) {
|
||||
|
||||
function cardPairHtml(c) {
|
||||
const cls = (c.currency || '').toLowerCase();
|
||||
const theme = c.exchange?.theme ? `theme-${c.exchange.theme}` : (cls === 'usd' ? 'theme-zbank' : cls);
|
||||
const cvv = cvvDisplay(c);
|
||||
const last4 = (c.panMasked || '').slice(-4);
|
||||
const exLabel = c.exchange?.name || 'Z Online Bank';
|
||||
return `<div class="flip-wrap" tabindex="0" title="Click to flip">
|
||||
<div class="flip-inner">
|
||||
<div class="face front ${cls}">
|
||||
<div class="face front ${cls} ${theme}">
|
||||
<div class="ex-name">${exLabel}</div>
|
||||
<div class="ccy-tag">${c.currency} FRONT</div>
|
||||
<div class="chip"></div>
|
||||
<div class="pan">${c.panMasked || '•••• •••• •••• ••••'}</div>
|
||||
@@ -284,13 +309,13 @@ function cardPairHtml(c) {
|
||||
<div class="cvv-box"><span>CVV</span>${cvv}</div>
|
||||
</div>
|
||||
<div class="back-meta">
|
||||
<strong>${c.currency} BACK</strong> · Z Online Bank<br>
|
||||
<strong>${exLabel}</strong> · ${c.currency} BACK<br>
|
||||
Debit · **** ${last4} · Exp ${c.expiry || '—'}<br>
|
||||
IBAN ${(c.linkedIban || '').slice(0, 8)}… · ATM / e‑comm enabled
|
||||
BIC ${c.exchange?.bicHint || 'ZBKNOMNLXXX'} · IBAN ${(c.linkedIban || '').slice(0, 8)}…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">hover / click → back</div>
|
||||
<div class="hint">hover / click → flip</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -302,10 +327,11 @@ function collectCards(data) {
|
||||
if (a.card) cards.push({ ...a.card, currency: a.currency, linkedIban: a.iban });
|
||||
for (const oc of a.onlineCards || []) cards.push(oc);
|
||||
}
|
||||
for (const ec of data.exchangeCards || []) cards.push(ec);
|
||||
const seen = new Set();
|
||||
const uniq = [];
|
||||
for (const c of cards) {
|
||||
const k = `${c.currency}:${c.panMasked}`;
|
||||
const k = `${c.currency}:${c.panMasked}:${c.exchange?.id || ''}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
uniq.push(c);
|
||||
@@ -320,7 +346,7 @@ async function load() {
|
||||
return;
|
||||
}
|
||||
const uniq = collectCards(await r.json());
|
||||
faces.innerHTML = uniq.slice(0, 9).map(cardPairHtml).join('') || '<p style="color:var(--muted)">No cards yet.</p>';
|
||||
faces.innerHTML = uniq.slice(0, 12).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) => {
|
||||
@@ -328,13 +354,14 @@ async function load() {
|
||||
});
|
||||
});
|
||||
tbody.innerHTML = uniq.map(c => `<tr>
|
||||
<td>${c.exchange?.name || 'Z Online'}</td>
|
||||
<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>';
|
||||
</tr>`).join('') || '<tr><td colspan="7">None yet</td></tr>';
|
||||
}
|
||||
|
||||
async function issueOne(currency, revealPan) {
|
||||
@@ -384,7 +411,7 @@ issueAllBtn.addEventListener('click', async () => {
|
||||
lines.push(`${currency} ${c.panMasked} CVV ${c.cvv || c.cvvMasked || '•••'} BACK READY`);
|
||||
}
|
||||
result.classList.add('show');
|
||||
result.innerHTML = `<span class="ok">CREATED 3× FRONT + BACK</span>\\n` + lines.join('\\n');
|
||||
result.innerHTML = `<span class="ok">CREATED 3× FRONT + BACK</span><br>` + lines.join('<br>');
|
||||
await load();
|
||||
} catch (e) {
|
||||
result.classList.add('show');
|
||||
@@ -394,6 +421,55 @@ issueAllBtn.addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function issueRandomPack(count) {
|
||||
const revealPan = document.getElementById('reveal').checked;
|
||||
const r = await fetch('/zbank/api/cards/random-exchanges', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count, revealPan }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||
for (const c of j.cards || []) {
|
||||
if (c.panMasked && (c.pan || c.cvv)) sessionSecrets[c.panMasked] = { pan: c.pan, cvv: c.cvv };
|
||||
}
|
||||
return j.cards || [];
|
||||
}
|
||||
|
||||
issueRandomBtn.addEventListener('click', async () => {
|
||||
issueRandomBtn.disabled = true;
|
||||
result.classList.remove('show');
|
||||
try {
|
||||
const cards = await issueRandomPack(4);
|
||||
result.classList.add('show');
|
||||
result.innerHTML = `<span class="ok">RANDOM EXCHANGE PACK ×${cards.length}</span><br>` +
|
||||
cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked}`).join('<br>');
|
||||
await load();
|
||||
} catch (e) {
|
||||
result.classList.add('show');
|
||||
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
issueRandomBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
issueRandom8Btn.addEventListener('click', async () => {
|
||||
issueRandom8Btn.disabled = true;
|
||||
result.classList.remove('show');
|
||||
try {
|
||||
const cards = await issueRandomPack(8);
|
||||
result.classList.add('show');
|
||||
result.innerHTML = `<span class="ok">FULL EXCHANGE PACK ×${cards.length}</span><br>` +
|
||||
cards.map(c => `${c.exchange?.name || '?'} · ${c.currency} · ${c.panMasked} · ${c.exchange?.bicHint || ''}`).join('<br>');
|
||||
await load();
|
||||
} catch (e) {
|
||||
result.classList.add('show');
|
||||
result.textContent = 'Error: ' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
issueRandom8Btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -36,7 +36,7 @@ import metamaskPriceRoutes from './routes/metamask-prices';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import { issueCardForCurrency, loadCustomerRegistry } from '../lib/zbank-online-cards';
|
||||
import { issueCardForCurrency, issueRandomExchangeCards, loadCustomerRegistry, EXCHANGE_CATALOG } from '../lib/zbank-online-cards';
|
||||
import winston from 'winston';
|
||||
|
||||
// Setup logger
|
||||
@@ -299,6 +299,22 @@ export class ApiServer {
|
||||
this.app.get('/zbank/cards', sendZbankOnlineCards);
|
||||
this.app.get('/zbank/online-cards', sendZbankOnlineCards);
|
||||
|
||||
const railsHubPath = path.join(__dirname, '../../public/rails-revolut-wise-binance.html');
|
||||
this.app.get('/rails/revolut-wise-binance', (_req: Request, res: Response) => {
|
||||
if (!existsSync(railsHubPath)) {
|
||||
res.status(404).type('text/plain').send('rails-revolut-wise-binance.html missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(railsHubPath, 'utf8'));
|
||||
});
|
||||
this.app.get('/zbank/rails', (_req: Request, res: Response) => {
|
||||
if (!existsSync(railsHubPath)) {
|
||||
res.status(404).type('text/plain').send('rails page missing');
|
||||
return;
|
||||
}
|
||||
res.type('html').send(readFileSync(railsHubPath, 'utf8'));
|
||||
});
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../../../..');
|
||||
this.app.get('/zbank/api/customer/zardasht', (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -317,7 +333,8 @@ export class ApiServer {
|
||||
try {
|
||||
const currency = String(req.body?.currency || 'USD').toUpperCase();
|
||||
const revealPan = Boolean(req.body?.revealPan);
|
||||
const out = issueCardForCurrency(repoRoot, currency, revealPan);
|
||||
const exchange = req.body?.exchange ? String(req.body.exchange) : undefined;
|
||||
const out = issueCardForCurrency(repoRoot, currency, revealPan, exchange);
|
||||
if (!out.ok) {
|
||||
res.status(400).json({ error: out.error });
|
||||
return;
|
||||
@@ -328,6 +345,25 @@ export class ApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/zbank/api/exchanges', (_req: Request, res: Response) => {
|
||||
res.json({ exchanges: EXCHANGE_CATALOG });
|
||||
});
|
||||
|
||||
this.app.post('/zbank/api/cards/random-exchanges', (req: Request, res: Response) => {
|
||||
try {
|
||||
const count = Math.min(8, Math.max(1, Number(req.body?.count || 4)));
|
||||
const revealPan = Boolean(req.body?.revealPan);
|
||||
const out = issueRandomExchangeCards(repoRoot, count, revealPan);
|
||||
if (!out.ok) {
|
||||
res.status(400).json({ error: out.error });
|
||||
return;
|
||||
}
|
||||
res.status(201).json({ ok: true, count: out.cards.length, cards: out.cards });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts)
|
||||
const sendApiV1Catalog = (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
|
||||
@@ -24,6 +24,14 @@ export type OnlineCard = {
|
||||
atmEnabled: boolean;
|
||||
contactless: boolean;
|
||||
createdAt: string;
|
||||
/** Exchange / ISB member branding on the plastic. */
|
||||
exchange?: {
|
||||
id: string;
|
||||
name: string;
|
||||
officeId: number | null;
|
||||
theme: string;
|
||||
bicHint: string;
|
||||
};
|
||||
/** Back-of-card panel (magstripe / signature / CVV area). */
|
||||
back: {
|
||||
magstripe: true;
|
||||
@@ -35,6 +43,40 @@ export type OnlineCard = {
|
||||
networks: Record<string, { status: string; note: string }>;
|
||||
};
|
||||
|
||||
/** OMNL ISB / partner exchanges for random branded cards. */
|
||||
export const EXCHANGE_CATALOG = [
|
||||
{ id: 'zbank', name: 'Z Online Bank', officeId: 29, theme: 'zbank', bicHint: 'ZBKNOMNLXXX' },
|
||||
{ id: 'novabank', name: 'NovaBank', officeId: 30, theme: 'nova', bicHint: 'NOVAGB2LXXX' },
|
||||
{ id: 'anakabank', name: 'AnakaBank', officeId: 31, theme: 'anaka', bicHint: 'ANAKOMNL33' },
|
||||
{ id: 'onex', name: 'OneX Exchange', officeId: null, theme: 'onex', bicHint: 'ONEXSHIV33' },
|
||||
{ id: 'omnl-fx', name: 'OMNL Forex Desk', officeId: 1, theme: 'omnl', bicHint: 'OMNLUS33XXX' },
|
||||
{ id: 'hermes', name: 'Hermes Desk', officeId: 32, theme: 'hermes', bicHint: 'HRMSOMNL33' },
|
||||
{ id: 'tsunami', name: 'Tsunami Exchange', officeId: 15, theme: 'tsunami', bicHint: 'TSUNOMNL33' },
|
||||
{ id: 'hybx', name: 'HYBX Rail', officeId: 3, theme: 'hybx', bicHint: 'HYBXOMNL33' },
|
||||
{ id: 'revolut', name: 'Revolut Business', officeId: null, theme: 'revolut', bicHint: 'REVOGB2LXXX' },
|
||||
{ id: 'wise', name: 'Wise Bank', officeId: null, theme: 'wise', bicHint: 'TRWIUS33XXX' },
|
||||
{ id: 'binance', name: 'Binance Exchange', officeId: null, theme: 'binance', bicHint: 'BINANCE' },
|
||||
] as const;
|
||||
|
||||
export type ExchangeDef = (typeof EXCHANGE_CATALOG)[number];
|
||||
|
||||
export function pickRandomExchanges(count: number): ExchangeDef[] {
|
||||
const pool = [...EXCHANGE_CATALOG];
|
||||
const out: ExchangeDef[] = [];
|
||||
const n = Math.min(Math.max(1, count), pool.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const idx = randomInt(0, pool.length);
|
||||
out.push(pool.splice(idx, 1)[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pickRandomCurrency(accounts: Array<{ currency?: string }>): string {
|
||||
const codes = accounts.map((a) => String(a.currency || '').toUpperCase()).filter(Boolean);
|
||||
if (codes.length === 0) return 'USD';
|
||||
return codes[randomInt(0, codes.length)];
|
||||
}
|
||||
|
||||
function luhnCheckDigit(partial: string): string {
|
||||
let sum = 0;
|
||||
let dbl = true;
|
||||
@@ -83,6 +125,7 @@ export function buildOnlineCard(opts: {
|
||||
iban: string;
|
||||
accountNo: string | null;
|
||||
nameOnCard: string;
|
||||
exchange?: ExchangeDef;
|
||||
}): OnlineCard & { pan?: string; cvv?: string } {
|
||||
const pan = issueOnlineVisaPan();
|
||||
const cvv = String(randomInt(100, 999));
|
||||
@@ -90,10 +133,12 @@ export function buildOnlineCard(opts: {
|
||||
const expYear = String(new Date().getFullYear() + 4).slice(-2);
|
||||
const id = `zcard_${Date.now().toString(36)}_${randomInt(1000, 9999)}`;
|
||||
const nameOnCard = opts.nameOnCard.toUpperCase();
|
||||
const exchange = opts.exchange;
|
||||
const product = exchange ? `${exchange.name} Visa` : 'Z Online Card';
|
||||
return {
|
||||
id,
|
||||
brand: 'Visa',
|
||||
product: 'Z Online Card',
|
||||
product,
|
||||
formFactor: 'online',
|
||||
currency: opts.currency,
|
||||
linkedIban: opts.iban,
|
||||
@@ -110,6 +155,17 @@ export function buildOnlineCard(opts: {
|
||||
createdAt: new Date().toISOString(),
|
||||
pan,
|
||||
cvv,
|
||||
...(exchange
|
||||
? {
|
||||
exchange: {
|
||||
id: exchange.id,
|
||||
name: exchange.name,
|
||||
officeId: exchange.officeId,
|
||||
theme: exchange.theme,
|
||||
bicHint: exchange.bicHint,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
back: {
|
||||
magstripe: true,
|
||||
signatureStrip: true,
|
||||
@@ -125,6 +181,10 @@ export function buildOnlineCard(opts: {
|
||||
westernUnion: { status: 'RAIL_MAPPED', note: 'WU via SWIFT/HYBX' },
|
||||
atm: { status: 'ENABLED_LEDGER', note: 'ATM when BIN switch live' },
|
||||
ecommerce: { status: 'ACTIVE', note: 'Online CNP spend on linked IBAN balance' },
|
||||
exchange: {
|
||||
status: exchange ? 'BRANDED' : 'ZBANK',
|
||||
note: exchange ? `${exchange.name} co-brand · BIC ${exchange.bicHint}` : 'Z Online Bank default',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -133,6 +193,7 @@ export function issueCardForCurrency(
|
||||
repoRoot: string,
|
||||
currency: string,
|
||||
revealPan = false,
|
||||
exchange?: ExchangeDef | string,
|
||||
): { ok: true; card: OnlineCard; account: Record<string, unknown> } | { ok: false; error: string } {
|
||||
const reg = loadCustomerRegistry(repoRoot);
|
||||
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
|
||||
@@ -141,19 +202,31 @@ export function issueCardForCurrency(
|
||||
if (!acct) return { ok: false, error: `No IBAN account for ${currency}` };
|
||||
|
||||
const customer = reg.customer as { displayName?: string };
|
||||
let ex: ExchangeDef | undefined;
|
||||
if (typeof exchange === 'string' && exchange) {
|
||||
ex = EXCHANGE_CATALOG.find((e) => e.id === exchange.toLowerCase() || e.theme === exchange.toLowerCase());
|
||||
} else if (exchange && typeof exchange === 'object') {
|
||||
ex = exchange;
|
||||
}
|
||||
|
||||
const card = buildOnlineCard({
|
||||
currency: String(acct.currency),
|
||||
iban: String(acct.iban),
|
||||
accountNo: acct.fineractAccountNo ? String(acct.fineractAccountNo) : null,
|
||||
nameOnCard: customer.displayName || 'CARDHOLDER',
|
||||
exchange: ex,
|
||||
});
|
||||
|
||||
const { pan, cvv, ...publicCard } = card;
|
||||
const onlineCards = (Array.isArray(acct.onlineCards) ? acct.onlineCards : []) as OnlineCard[];
|
||||
onlineCards.push(publicCard);
|
||||
acct.onlineCards = onlineCards;
|
||||
// Primary online card pointer
|
||||
acct.onlineCard = publicCard;
|
||||
|
||||
const exchangeCards = (Array.isArray(reg.exchangeCards) ? reg.exchangeCards : []) as OnlineCard[];
|
||||
if (ex) exchangeCards.push(publicCard);
|
||||
reg.exchangeCards = exchangeCards;
|
||||
|
||||
saveCustomerRegistry(repoRoot, reg);
|
||||
|
||||
const out: OnlineCard & { pan?: string; cvv?: string } = { ...publicCard };
|
||||
@@ -163,3 +236,25 @@ export function issueCardForCurrency(
|
||||
}
|
||||
return { ok: true, card: out, account: acct };
|
||||
}
|
||||
|
||||
/** Issue N randomly selected exchange-branded cards across funded IBANs. */
|
||||
export function issueRandomExchangeCards(
|
||||
repoRoot: string,
|
||||
count = 4,
|
||||
revealPan = false,
|
||||
): { ok: true; cards: OnlineCard[] } | { ok: false; error: string } {
|
||||
const reg = loadCustomerRegistry(repoRoot);
|
||||
if (!reg) return { ok: false, error: 'Customer registry missing — run provision script first' };
|
||||
const accounts = (reg.accounts as Array<Record<string, unknown>>) || [];
|
||||
if (accounts.length === 0) return { ok: false, error: 'No IBAN accounts' };
|
||||
|
||||
const exchanges = pickRandomExchanges(count);
|
||||
const cards: OnlineCard[] = [];
|
||||
for (const ex of exchanges) {
|
||||
const currency = pickRandomCurrency(accounts as Array<{ currency?: string }>);
|
||||
const issued = issueCardForCurrency(repoRoot, currency, revealPan, ex);
|
||||
if (!issued.ok) return { ok: false, error: issued.error };
|
||||
cards.push(issued.card);
|
||||
}
|
||||
return { ok: true, cards };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user