feat: OMNL Bank online production - 128 chains, Central Bank UI, settlement stack
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m29s
CI/CD Pipeline / Security Scanning (push) Successful in 3m36s
CI/CD Pipeline / Lint and Format (push) Failing after 47s
CI/CD Pipeline / Terraform Validation (push) Failing after 32s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 32s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 52s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 26s
Validation / validate-genesis (push) Successful in 28s
Validation / validate-terraform (push) Failing after 45s
Validation / validate-kubernetes (push) Failing after 17s
Validation / validate-smart-contracts (push) Failing after 17s
Validation / validate-security (push) Failing after 1m53s
Validation / validate-documentation (push) Failing after 20s
Verify Deployment / Verify Deployment (push) Failing after 1m22s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m29s
CI/CD Pipeline / Security Scanning (push) Successful in 3m36s
CI/CD Pipeline / Lint and Format (push) Failing after 47s
CI/CD Pipeline / Terraform Validation (push) Failing after 32s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 32s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 52s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 26s
Validation / validate-genesis (push) Successful in 28s
Validation / validate-terraform (push) Failing after 45s
Validation / validate-kubernetes (push) Failing after 17s
Validation / validate-smart-contracts (push) Failing after 17s
Validation / validate-security (push) Failing after 1m53s
Validation / validate-documentation (push) Failing after 20s
Verify Deployment / Verify Deployment (push) Failing after 1m22s
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"primaryChainId": 138,
|
||||
"mirrorChainId": 651940,
|
||||
"settlementOfficeId": 24,
|
||||
"generatedAt": "2026-06-28T15:22:27.822Z",
|
||||
"generatedAt": "2026-07-05T14:44:58.371Z",
|
||||
"chains": [
|
||||
{
|
||||
"chainId": 138,
|
||||
@@ -1224,23 +1224,20 @@
|
||||
},
|
||||
{
|
||||
"chainId": 900001,
|
||||
"name": "Shiva Blockchain (OneX)",
|
||||
"name": "OMNL Expansion Slot 1",
|
||||
"type": "evm",
|
||||
"nativeSymbol": "SHIVA",
|
||||
"tier": "primary",
|
||||
"omnlRole": "onex-shiva",
|
||||
"status": "active",
|
||||
"nativeSymbol": "ETH",
|
||||
"tier": "reserved",
|
||||
"omnlRole": "expansion",
|
||||
"status": "reserved",
|
||||
"rpcEnv": "CHAIN_900001_RPC_URL",
|
||||
"explorerUrl": "https://explorer.shiva.onex.omdnl.org",
|
||||
"rpcDefault": "https://rpc.shiva.onex.omdnl.org",
|
||||
"settlement": {
|
||||
"m2LoadEnabled": true,
|
||||
"swapEnabled": true,
|
||||
"bridgeEnabled": true,
|
||||
"officeId": 29
|
||||
"m2LoadEnabled": false,
|
||||
"swapEnabled": false,
|
||||
"bridgeEnabled": false,
|
||||
"officeId": 24
|
||||
},
|
||||
"complianceEnv": "OMNL_COMPLIANCE_CORE_900001",
|
||||
"integrationRef": "config/onex-shiva-integration.v1.json"
|
||||
"complianceEnv": "OMNL_COMPLIANCE_CORE_900001"
|
||||
},
|
||||
{
|
||||
"chainId": 900002,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"requireApiKey": true,
|
||||
"allowHybxProduction": true,
|
||||
"allowChainMintExecute": true,
|
||||
"allowChainBridgeExecute": true,
|
||||
"onlineBankMode": true
|
||||
},
|
||||
"guosmm": {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"production": {
|
||||
"requireApiKey": true,
|
||||
"allowHybxProduction": false,
|
||||
"allowChainMintExecute": false
|
||||
"allowChainMintExecute": false,
|
||||
"allowChainBridgeExecute": false
|
||||
}
|
||||
}
|
||||
|
||||
74
config/zbank-consumer-account.v1.json
Normal file
74
config/zbank-consumer-account.v1.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "zBank consumer account — Revolut-parity profile, IBAN, limits",
|
||||
"version": "1.0.0",
|
||||
"profile": {
|
||||
"plan": "Premium",
|
||||
"planTier": "premium",
|
||||
"planPrice": "£8.99/month",
|
||||
"email": "account@zbank.omdnl.org",
|
||||
"phone": "+964 770 000 0000",
|
||||
"address": {
|
||||
"line1": "zBank HQ — International Settlement Bank",
|
||||
"city": "Baghdad",
|
||||
"postcode": "10001",
|
||||
"country": "IQ",
|
||||
"countryName": "Iraq"
|
||||
},
|
||||
"memberSince": "2024-01-15",
|
||||
"kycStatus": "verified",
|
||||
"kycLevel": "standard"
|
||||
},
|
||||
"accountDetails": {
|
||||
"USD": {
|
||||
"accountName": "US Dollar",
|
||||
"iban": "IQ67OMNL000100293849384",
|
||||
"bic": "OMNLIQBA",
|
||||
"accountNumber": "00293849384",
|
||||
"routingNumber": "021000089",
|
||||
"bankName": "zBank — ISB member (OneX Bank)",
|
||||
"bankAddress": "Baghdad, Republic of Iraq",
|
||||
"shareText": "Use these details to receive USD via SWIFT or local rails."
|
||||
},
|
||||
"EUR": {
|
||||
"accountName": "Euro",
|
||||
"iban": "LT12 3250 0100 1234 5678",
|
||||
"bic": "REVOLT21",
|
||||
"accountNumber": "12345678",
|
||||
"sortCode": null,
|
||||
"bankName": "zBank EUR correspondent",
|
||||
"bankAddress": "Vilnius, Lithuania",
|
||||
"shareText": "Receive EUR via SEPA Instant."
|
||||
},
|
||||
"GBP": {
|
||||
"accountName": "British Pound",
|
||||
"iban": null,
|
||||
"bic": "OMNLGB2L",
|
||||
"accountNumber": "12345678",
|
||||
"sortCode": "04-00-75",
|
||||
"bankName": "zBank GBP account",
|
||||
"bankAddress": "London, United Kingdom",
|
||||
"shareText": "Receive GBP via Faster Payments."
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"currency": "USD",
|
||||
"dailyTransferLimit": "100000",
|
||||
"dailyTransferUsed": "0",
|
||||
"monthlyTransferLimit": "1000000",
|
||||
"monthlyTransferUsed": "0",
|
||||
"cardSpendDailyLimit": "25000",
|
||||
"cardSpendDailyUsed": "0",
|
||||
"atmDailyLimit": "3000",
|
||||
"atmDailyUsed": "0",
|
||||
"topUpMonthlyLimit": "500000"
|
||||
},
|
||||
"cardTierMap": {
|
||||
"z-prepaid-usd": { "tier": "standard", "label": "Standard", "gradient": "standard", "network": "Visa" },
|
||||
"z-debit-onex": { "tier": "metal", "label": "Metal", "gradient": "metal", "network": "Mastercard" }
|
||||
},
|
||||
"virtualCardProducts": [
|
||||
{ "tier": "standard", "label": "Standard", "gradient": "standard", "productId": "z-prepaid-usd", "network": "Visa" },
|
||||
{ "tier": "metal", "label": "Metal", "gradient": "metal", "productId": "z-debit-onex", "network": "Mastercard" },
|
||||
{ "tier": "premium", "label": "Premium", "gradient": "premium", "productId": "z-premium-usd", "network": "Visa" }
|
||||
]
|
||||
}
|
||||
21
config/zbank-portal-auth.v1.json
Normal file
21
config/zbank-portal-auth.v1.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "zBank international portal login — phone + passcode (Revolut-style)",
|
||||
"version": "1.0.0",
|
||||
"sessionTtlHours": 12,
|
||||
"maxAttempts": 5,
|
||||
"lockoutMinutes": 15,
|
||||
"users": [
|
||||
{
|
||||
"id": "zbank-operator-1",
|
||||
"phone": "+9647700000000",
|
||||
"displayName": "Z Bank Operator",
|
||||
"email": "account@zbank.omdnl.org"
|
||||
},
|
||||
{
|
||||
"id": "zbank-operator-2",
|
||||
"phone": "+9647700000001",
|
||||
"displayName": "Office 29 Treasury",
|
||||
"email": "treasury@zbank.omdnl.org"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import HubPage from './pages/HubPage'
|
||||
import WalletsDemoPage from './pages/WalletsDemoPage'
|
||||
import Layout from './components/layout/Layout'
|
||||
import OmnlProductLayout from './components/layout/OmnlProductLayout'
|
||||
import RevolutBankLayout from './components/layout/RevolutBankLayout'
|
||||
import ToastProvider from './components/ui/ToastProvider'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -51,11 +52,13 @@ function App() {
|
||||
<Routes>
|
||||
<Route element={<OmnlProductLayout />}>
|
||||
<Route path="/hub" element={<HubPage />} />
|
||||
<Route path="/central-bank" element={<CentralBankPage />} />
|
||||
<Route path="/office-24" element={<Office24Page />} />
|
||||
<Route path="/trade" element={<TradePage />} />
|
||||
<Route path="/swap" element={<SwapPage />} />
|
||||
</Route>
|
||||
<Route element={<RevolutBankLayout />}>
|
||||
<Route path="/central-bank" element={<CentralBankPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
<ThirdwebProvider clientId={THIRDWEB_CLIENT_ID}>
|
||||
|
||||
@@ -34,7 +34,11 @@ export default function OmnlProductLayout() {
|
||||
return (
|
||||
<div className={`omnl-app min-h-screen flex flex-col dbis-has-bottom-nav${isBanking ? ' omnl-banking-theme' : ''}`}>
|
||||
<WalletDisconnectNotice userInitiatedDisconnectRef={userInitiatedDisconnectRef} />
|
||||
<header className="omnl-app-header mobile-nav-bar sticky top-0 z-50 border-b border-green-500/20 safe-area-top bg-[#1a241c]">
|
||||
<header className={`omnl-app-header mobile-nav-bar sticky top-0 z-50 border-b safe-area-top ${
|
||||
isBanking
|
||||
? 'border-white/10 bg-black'
|
||||
: 'border-green-500/20 bg-[#1a241c]'
|
||||
}`}>
|
||||
<div className="max-w-[1600px] mx-auto px-3 sm:px-4 h-14 flex items-center gap-2 sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@@ -46,7 +50,9 @@ export default function OmnlProductLayout() {
|
||||
☰
|
||||
</button>
|
||||
<Link to="/hub" className="flex items-center gap-2 shrink-0 min-w-0">
|
||||
<span className="w-8 h-8 rounded-lg bg-green-600 text-white font-bold flex items-center justify-center text-sm shrink-0">
|
||||
<span className={`w-8 h-8 rounded-lg font-bold flex items-center justify-center text-sm shrink-0 ${
|
||||
isBanking ? 'bg-[#494fdf] text-white' : 'bg-green-600 text-white'
|
||||
}`}>
|
||||
<NasaIcon name="central-bank" size={16} />
|
||||
</span>
|
||||
<span className="font-semibold hidden sm:inline text-sm truncate text-white">DBIS OMNL</span>
|
||||
|
||||
36
frontend-dapp/src/components/layout/RevolutBankLayout.tsx
Normal file
36
frontend-dapp/src/components/layout/RevolutBankLayout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { useRef } from 'react';
|
||||
import WalletDisconnectNotice from '../wallet/WalletDisconnectNotice';
|
||||
import RevolutLoginScreen from '../../features/online-bank/RevolutLoginScreen';
|
||||
import { useZBankAuth } from '../../features/online-bank/useZBankAuth';
|
||||
|
||||
/** Full-screen Revolut-style shell — no DBIS product header. */
|
||||
export default function RevolutBankLayout() {
|
||||
const userInitiatedDisconnectRef = useRef(false);
|
||||
const { loading, authenticated, requireAuth, refresh, login } = useZBankAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="revolut-app-shell omnl-banking-theme revolut-login revolut-login--loading">
|
||||
<p>Loading secure portal…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuth && !authenticated) {
|
||||
return (
|
||||
<div className="revolut-app-shell omnl-banking-theme">
|
||||
<RevolutLoginScreen login={login} onSuccess={() => refresh()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revolut-app-shell omnl-banking-theme">
|
||||
<WalletDisconnectNotice userInitiatedDisconnectRef={userInitiatedDisconnectRef} />
|
||||
<main className="revolut-app-shell__main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
/** DBIS Exchange — Chain 138 DEX addresses (Pancake/Uniswap V2 compatible) */
|
||||
export const DBIS_EXCHANGE_CHAIN_ID = 138;
|
||||
|
||||
/** Production portal hosts — ignored in Vite dev so local proxy + localhost services are used. */
|
||||
const REMOTE_PORTAL_HOST = /secure\.omdnl\.org|exchange\.omdnl\.org|explorer\.d-bis\.org/i;
|
||||
|
||||
function prodOrLocal(envValue: string | undefined, prodPath: string, localUrl: string): string {
|
||||
const trimmed = envValue?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return import.meta.env.PROD ? prodPath : localUrl;
|
||||
if (import.meta.env.PROD) {
|
||||
return trimmed || prodPath;
|
||||
}
|
||||
if (trimmed && !REMOTE_PORTAL_HOST.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return localUrl;
|
||||
}
|
||||
|
||||
export const TOKEN_AGGREGATION_URL = prodOrLocal(
|
||||
import.meta.env.VITE_TOKEN_AGGREGATION_URL,
|
||||
'https://explorer.d-bis.org/api/v1',
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3000/api/v1',
|
||||
);
|
||||
export const DBIS_EXCHANGE_URL = prodOrLocal(
|
||||
import.meta.env.VITE_DBIS_EXCHANGE_URL,
|
||||
@@ -24,9 +32,21 @@ export const SETTLEMENT_MIDDLEWARE_URL = prodOrLocal(
|
||||
);
|
||||
|
||||
/** Production nginx maps /settlement/* and /exchange/* to middleware; bare localhost needs /api/v1/... */
|
||||
function proxiedServiceUrl(base: string, servicePrefix: string, path: string): string {
|
||||
function isLocalDevOrigin(base: string): boolean {
|
||||
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(base.replace(/\/$/, ''));
|
||||
}
|
||||
|
||||
function proxiedServiceUrl(
|
||||
base: string,
|
||||
sameOriginPrefix: string,
|
||||
servicePrefix: string,
|
||||
path: string,
|
||||
): string {
|
||||
const root = base.replace(/\/$/, '');
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`;
|
||||
if (import.meta.env.DEV && isLocalDevOrigin(root)) {
|
||||
return `${sameOriginPrefix}${suffix}`;
|
||||
}
|
||||
if (root.startsWith('/')) {
|
||||
return `${root}${suffix}`;
|
||||
}
|
||||
@@ -35,11 +55,15 @@ function proxiedServiceUrl(base: string, servicePrefix: string, path: string): s
|
||||
}
|
||||
|
||||
export function settlementApi(path: string): string {
|
||||
return proxiedServiceUrl(SETTLEMENT_MIDDLEWARE_URL, '/api/v1/settlement', path);
|
||||
return proxiedServiceUrl(SETTLEMENT_MIDDLEWARE_URL, '/settlement', '/api/v1/settlement', path);
|
||||
}
|
||||
|
||||
export function exchangeApi(path: string): string {
|
||||
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path);
|
||||
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/exchange', '/api/v1/exchange', path);
|
||||
}
|
||||
|
||||
export function tokenAggregationApi(path: string): string {
|
||||
return proxiedServiceUrl(TOKEN_AGGREGATION_URL, '/api/v1', '/api/v1', path);
|
||||
}
|
||||
|
||||
/** Bearer auth for OMNL mutating APIs — set VITE_OMNL_API_KEY at build for tunnel deploy; nginx injects in prod portals. */
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function GuosmmNoticesPanel() {
|
||||
<h2 id="guosmm-heading" className="revolut-bank__section-title">
|
||||
Official gazette (GUOSMM)
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--nasa-muted)] mb-3 max-w-2xl">
|
||||
<p className="revolut-bank__gazette-hint mb-3 max-w-2xl">
|
||||
Sovereign Military Order of Malta · SHOSJJ official notices governing OMNL EMCB and ISB member banks.
|
||||
</p>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
|
||||
@@ -1,242 +1,276 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import WalletConnect from '../../components/wallet/WalletConnect';
|
||||
import NasaIcon from '../../components/icons/NasaIcon';
|
||||
import { formatUsd } from '../../utils/formatMoney';
|
||||
import CentralBankDashboard from '../central-bank/CentralBankDashboard';
|
||||
import SettlementProtocolsPanel from './SettlementProtocolsPanel';
|
||||
import GuosmmNoticesPanel from './GuosmmNoticesPanel';
|
||||
import ZBankFullSettlementPanel from './ZBankFullSettlementPanel';
|
||||
import ZBankPosCardsPanel from './ZBankPosCardsPanel';
|
||||
import RevolutHomeScreen from './RevolutHomeScreen';
|
||||
import RevolutTabBar, { type RevolutTab } from './RevolutTabBar';
|
||||
import { useOnlineBank } from './useOnlineBank';
|
||||
import { useRevolutBank } from './useRevolutBank';
|
||||
|
||||
const ACCOUNTS = [
|
||||
{ key: 'main', label: 'USD account', meta: 'Main · M1 eMoney', flag: 'USD', balanceKey: 'primaryBalance' as const, active: true },
|
||||
{ key: 'savings', label: 'Savings', meta: 'M2 tokenized', flag: 'M2', balanceKey: 'savingsBalance' as const },
|
||||
{ key: 'reserve', label: 'Reserve', meta: 'M0 base money', flag: 'M0', balanceKey: 'reserveBalance' as const },
|
||||
{ key: 'fiat', label: 'Fiat rails', meta: 'M4 SWIFT settlement', flag: 'M4', balanceKey: 'm4Balance' as const },
|
||||
{ key: 'chain', label: 'On-chain', meta: 'M3 digital', flag: 'M3', balanceKey: 'm3Balance' as const },
|
||||
];
|
||||
|
||||
function BalanceSkeleton() {
|
||||
return (
|
||||
<section className="revolut-bank__balance-card" aria-hidden>
|
||||
<div className="revolut-bank__skeleton h-4 w-28 mb-3" />
|
||||
<div className="revolut-bank__skeleton h-12 w-56" />
|
||||
</section>
|
||||
);
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (parts[0]?.slice(0, 2) ?? 'ZB').toUpperCase();
|
||||
}
|
||||
|
||||
export default function OnlineBankApp() {
|
||||
const bank = useOnlineBank();
|
||||
const revolut = useRevolutBank();
|
||||
const { hash } = useLocation();
|
||||
const [tab, setTab] = useState<RevolutTab>('home');
|
||||
const [balanceHidden, setBalanceHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) return;
|
||||
if (/z-full-settlement|z-settlement|z-pos/.test(hash)) {
|
||||
setTab('payments');
|
||||
const id = hash.replace(/^#/, '');
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
}, [hash]);
|
||||
|
||||
const goPayments = () => setTab('payments');
|
||||
const goCards = () => setTab('payments');
|
||||
|
||||
const displayName = revolut.overview?.profile.displayName ?? bank.displayName;
|
||||
const refreshAll = () => {
|
||||
bank.refresh();
|
||||
revolut.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="revolut-bank omnl-app">
|
||||
<div className="revolut-bank">
|
||||
<div className="revolut-bank__inner">
|
||||
<header className="revolut-bank__page-header">
|
||||
<div className="revolut-bank__logo-row">
|
||||
<div className="revolut-bank__logo-mark" aria-hidden>
|
||||
Z
|
||||
</div>
|
||||
<div className="revolut-bank__brand-block">
|
||||
<h1 className="revolut-bank__brand-title">Z Online Bank</h1>
|
||||
<p className="revolut-bank__greeting-label">
|
||||
Hello · Good {timeOfDay()}, <span className="revolut-bank__greeting-name">{bank.displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`revolut-bank__status${bank.connected ? '' : ' revolut-bank__status--offline'}`}>
|
||||
<NasaIcon name={bank.connected ? 'check' : 'x'} size={10} />
|
||||
{bank.connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{bank.loading && !bank.primaryBalance ? (
|
||||
<BalanceSkeleton />
|
||||
) : (
|
||||
<section className="revolut-bank__balance-card" aria-label="Total balance">
|
||||
<p className="revolut-bank__balance-label">Total balance</p>
|
||||
<p className="revolut-bank__balance-amount">{formatUsd(bank.primaryBalance)}</p>
|
||||
<div className="revolut-bank__balance-sub">
|
||||
<span>{bank.officeLabel}</span>
|
||||
{bank.live && <span>Synced with Fineract</span>}
|
||||
</div>
|
||||
</section>
|
||||
{tab === 'home' && (
|
||||
<RevolutHomeScreen
|
||||
overview={revolut.overview}
|
||||
loading={revolut.loading}
|
||||
balanceHidden={balanceHidden}
|
||||
onToggleBalance={() => setBalanceHidden((v) => !v)}
|
||||
onGoPayments={goPayments}
|
||||
onGoCards={goCards}
|
||||
initials={initials(displayName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="revolut-bank__cta-row">
|
||||
<a href="#z-full-settlement" className="revolut-bank__cta revolut-bank__cta--primary">
|
||||
<NasaIcon name="money" size={18} />
|
||||
Send
|
||||
</a>
|
||||
<a href="#z-full-settlement" className="revolut-bank__cta revolut-bank__cta--secondary">
|
||||
<NasaIcon name="plus" size={18} />
|
||||
Add money
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section className="revolut-bank__accounts-section" aria-label="Your accounts">
|
||||
<h2 className="revolut-bank__accounts-heading">Your accounts</h2>
|
||||
<div className="revolut-bank__accounts">
|
||||
{ACCOUNTS.map((acct) => (
|
||||
<div
|
||||
key={acct.key}
|
||||
className={`revolut-bank__account-pill${acct.active ? ' revolut-bank__account-pill--active' : ''}`}
|
||||
>
|
||||
<span className="revolut-bank__account-flag">{acct.flag}</span>
|
||||
<div className="revolut-bank__account-pill-body">
|
||||
<p className="revolut-bank__account-pill-label">{acct.label}</p>
|
||||
<p className="revolut-bank__account-pill-meta">{acct.meta}</p>
|
||||
</div>
|
||||
<p className="revolut-bank__account-pill-value">{formatUsd(bank[acct.balanceKey])}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="revolut-bank__actions" aria-label="Quick actions">
|
||||
<Link to="/swap" className="revolut-bank__action">
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="swap" size={20} />
|
||||
</span>
|
||||
Convert
|
||||
</Link>
|
||||
<Link to="/trade" className="revolut-bank__action">
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="trade" size={20} />
|
||||
</span>
|
||||
Invest
|
||||
</Link>
|
||||
<a href="#z-settlement-protocols" className="revolut-bank__action">
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="globe" size={20} />
|
||||
</span>
|
||||
International
|
||||
</a>
|
||||
<Link to="/office-24" className="revolut-bank__action">
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="office" size={20} />
|
||||
</span>
|
||||
O24 Central Bank
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<ZBankFullSettlementPanel
|
||||
onSuccess={() => bank.refresh()}
|
||||
balances={{
|
||||
m0: bank.reserveBalance,
|
||||
m1: bank.primaryBalance,
|
||||
m2: bank.savingsBalance,
|
||||
m3: bank.m3Balance,
|
||||
m4: bank.m4Balance,
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettlementProtocolsPanel onSuccess={() => bank.refresh()} />
|
||||
|
||||
{bank.error && (
|
||||
<div className="revolut-bank__activity revolut-bank__error mb-4 p-4 text-sm">
|
||||
{bank.error}. Showing cached layout — retrying every 30s.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="activity-heading">
|
||||
<div className="revolut-bank__section-title">
|
||||
<h2 id="activity-heading">Transactions</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__section-link border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => bank.refresh()}
|
||||
>
|
||||
See all
|
||||
</button>
|
||||
</div>
|
||||
<div className="revolut-bank__activity">
|
||||
{bank.activities.length === 0 ? (
|
||||
<div className="revolut-bank__activity-item">
|
||||
<span className="revolut-bank__activity-icon">
|
||||
<NasaIcon name="ledger" size={16} />
|
||||
{tab === 'invest' && (
|
||||
<div className="revolut-bank__tab-screen">
|
||||
<header className="revolut-bank__tab-header">
|
||||
<h1>Invest</h1>
|
||||
<p className="revolut-bank__tab-sub">Stocks, crypto & markets</p>
|
||||
</header>
|
||||
<div className="revolut-bank__services">
|
||||
<Link to="/trade" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
|
||||
<NasaIcon name="trade" size={20} />
|
||||
</span>
|
||||
<div className="revolut-bank__activity-body">
|
||||
<p className="revolut-bank__activity-title">No transactions yet</p>
|
||||
<p className="revolut-bank__activity-meta">Swaps and settlements appear here</p>
|
||||
</div>
|
||||
<span className="revolut-bank__service-label">Stocks</span>
|
||||
<span className="revolut-bank__service-desc">DBIS spot · commission-free</span>
|
||||
</Link>
|
||||
<Link to="/swap" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--teal">
|
||||
<NasaIcon name="swap" size={20} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Crypto</span>
|
||||
<span className="revolut-bank__service-desc">Buy & exchange on chain 138</span>
|
||||
</Link>
|
||||
<Link to="/wallets" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--blue">
|
||||
<NasaIcon name="wallet" size={20} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">OneX Wallet</span>
|
||||
<span className="revolut-bank__service-desc">MPC · Shiva blockchain</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'payments' && (
|
||||
<div className="revolut-bank__tab-screen">
|
||||
<header className="revolut-bank__tab-header">
|
||||
<h1>Payments</h1>
|
||||
<p className="revolut-bank__tab-sub">Send, settle & load cards</p>
|
||||
</header>
|
||||
{(bank.error || revolut.error) && (
|
||||
<div className="revolut-bank__activity revolut-bank__error mb-4 p-4 text-sm">
|
||||
{revolut.error ?? bank.error}
|
||||
</div>
|
||||
) : (
|
||||
bank.activities.map((item) => (
|
||||
<div key={item.id} className="revolut-bank__activity-item">
|
||||
<span
|
||||
className={`revolut-bank__activity-icon${item.kind === 'swap' ? ' revolut-bank__activity-icon--swap' : ''}`}
|
||||
>
|
||||
<NasaIcon name={item.kind === 'swap' ? 'swap' : 'check'} size={16} />
|
||||
</span>
|
||||
<div className="revolut-bank__activity-body">
|
||||
<p className="revolut-bank__activity-title">{item.title}</p>
|
||||
<p className="revolut-bank__activity-meta">{item.meta}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`revolut-bank__activity-amount${item.positive ? ' revolut-bank__activity-amount--positive' : ''}`}
|
||||
>
|
||||
{item.amount}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<ZBankFullSettlementPanel
|
||||
onSuccess={refreshAll}
|
||||
balances={{
|
||||
m0: bank.reserveBalance,
|
||||
m1: bank.primaryBalance,
|
||||
m2: bank.savingsBalance,
|
||||
m3: bank.m3Balance,
|
||||
m4: bank.m4Balance,
|
||||
}}
|
||||
/>
|
||||
<SettlementProtocolsPanel onSuccess={refreshAll} />
|
||||
<ZBankPosCardsPanel />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="services-heading">
|
||||
<h2 id="services-heading" className="revolut-bank__section-title">
|
||||
Explore
|
||||
</h2>
|
||||
<div className="revolut-bank__services">
|
||||
<Link to="/swap" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">Digital Swap</span>
|
||||
<span className="revolut-bank__service-desc">Convert & load M2 tokens</span>
|
||||
</Link>
|
||||
<Link to="/trade" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">DBIS Trade</span>
|
||||
<span className="revolut-bank__service-desc">Spot markets</span>
|
||||
</Link>
|
||||
<Link to="/office-24" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">Office 24 Central Bank</span>
|
||||
<span className="revolut-bank__service-desc">128-bank corridors · IPSAS</span>
|
||||
</Link>
|
||||
<Link to="/wallets" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">OneX Wallet</span>
|
||||
<span className="revolut-bank__service-desc">Shiva blockchain · MPC wallet</span>
|
||||
</Link>
|
||||
<a href="#z-settlement-protocols" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">OneX Bank</span>
|
||||
<span className="revolut-bank__service-desc">HYBX corridor · office 29</span>
|
||||
</a>
|
||||
<a href="#z-settlement-protocols" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">Nova Bank Online</span>
|
||||
<span className="revolut-bank__service-desc">Import wires · M4</span>
|
||||
</a>
|
||||
<Link to="/reserve" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-label">Reserve</span>
|
||||
<span className="revolut-bank__service-desc">Coverage & attestation</span>
|
||||
</Link>
|
||||
{tab === 'hub' && (
|
||||
<div className="revolut-bank__tab-screen">
|
||||
<header className="revolut-bank__tab-header">
|
||||
<h1>Hub</h1>
|
||||
<p className="revolut-bank__tab-sub">All products in one place</p>
|
||||
</header>
|
||||
<div className="revolut-bank__services revolut-bank__services--hub">
|
||||
<Link to="/swap" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--teal">
|
||||
<NasaIcon name="swap" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Exchange</span>
|
||||
<span className="revolut-bank__service-desc">Convert & load M2</span>
|
||||
</Link>
|
||||
<Link to="/trade" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
|
||||
<NasaIcon name="trade" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Stocks</span>
|
||||
<span className="revolut-bank__service-desc">Spot markets</span>
|
||||
</Link>
|
||||
<Link to="/office-24" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--pink">
|
||||
<NasaIcon name="office" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Office 24</span>
|
||||
<span className="revolut-bank__service-desc">128-bank corridors</span>
|
||||
</Link>
|
||||
<Link to="/wallets" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--blue">
|
||||
<NasaIcon name="wallet" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Crypto</span>
|
||||
<span className="revolut-bank__service-desc">OneX · MPC wallet</span>
|
||||
</Link>
|
||||
<button type="button" className="revolut-bank__service-card" onClick={goPayments}>
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--orange">
|
||||
<NasaIcon name="globe" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">International</span>
|
||||
<span className="revolut-bank__service-desc">Wires & corridors</span>
|
||||
</button>
|
||||
<Link to="/reserve" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--grey">
|
||||
<NasaIcon name="shield" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Vaults</span>
|
||||
<span className="revolut-bank__service-desc">Reserve attestation</span>
|
||||
</Link>
|
||||
<Link to="/bridge" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--lime">
|
||||
<NasaIcon name="bridge" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">Bridge</span>
|
||||
<span className="revolut-bank__service-desc">Cross-chain transfer</span>
|
||||
</Link>
|
||||
<Link to="/hub" className="revolut-bank__service-card">
|
||||
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
|
||||
<NasaIcon name="dashboard" size={18} />
|
||||
</span>
|
||||
<span className="revolut-bank__service-label">DBIS Dashboard</span>
|
||||
<span className="revolut-bank__service-desc">Operator consoles</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<GuosmmNoticesPanel />
|
||||
{tab === 'more' && (
|
||||
<div className="revolut-bank__tab-screen">
|
||||
<header className="revolut-bank__tab-header">
|
||||
<h1>More</h1>
|
||||
<p className="revolut-bank__tab-sub">Settings & operator tools</p>
|
||||
</header>
|
||||
<div className="revolut-bank__profile-card">
|
||||
<div className="revolut-bank__avatar revolut-bank__avatar--lg">{initials(displayName)}</div>
|
||||
<div>
|
||||
<p className="revolut-bank__profile-name">{displayName}</p>
|
||||
<p className="revolut-bank__profile-meta">
|
||||
{revolut.overview?.profile.plan ?? 'Standard'} · {revolut.overview?.profile.externalId ?? bank.officeLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="revolut-bank__treasury">
|
||||
<summary>
|
||||
Treasury & operator console
|
||||
<NasaIcon name="arrow-right" size={14} className="revolut-bank__chevron" />
|
||||
</summary>
|
||||
<div className="revolut-bank__treasury-body">
|
||||
<CentralBankDashboard />
|
||||
{revolut.overview && (
|
||||
<section className="revolut-bank__limits" aria-label="Account limits">
|
||||
<h2 className="revolut-bank__section-title">Limits</h2>
|
||||
<div className="revolut-bank__limits-grid">
|
||||
<LimitRow label="Daily transfer" used={revolut.overview.limits.dailyTransferUsed} limit={revolut.overview.limits.dailyTransferLimit} />
|
||||
<LimitRow label="Monthly transfer" used={revolut.overview.limits.monthlyTransferUsed} limit={revolut.overview.limits.monthlyTransferLimit} />
|
||||
<LimitRow label="Card spend (daily)" used={revolut.overview.limits.cardSpendDailyUsed} limit={revolut.overview.limits.cardSpendDailyLimit} />
|
||||
<LimitRow label="ATM (daily)" used={revolut.overview.limits.atmDailyUsed} limit={revolut.overview.limits.atmDailyLimit} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="revolut-bank__profile-details">
|
||||
<h2 className="revolut-bank__section-title">Personal details</h2>
|
||||
<dl className="revolut-bank__detail-list revolut-bank__detail-list--inline">
|
||||
<div className="revolut-bank__detail-row">
|
||||
<dt>Email</dt>
|
||||
<dd>{revolut.overview?.profile.email ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="revolut-bank__detail-row">
|
||||
<dt>Phone</dt>
|
||||
<dd>{revolut.overview?.profile.phone ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="revolut-bank__detail-row">
|
||||
<dt>KYC</dt>
|
||||
<dd>{revolut.overview?.profile.kycStatus ?? '—'} ({revolut.overview?.profile.kycLevel ?? '—'})</dd>
|
||||
</div>
|
||||
<div className="revolut-bank__detail-row">
|
||||
<dt>Member since</dt>
|
||||
<dd>{revolut.overview?.profile.memberSince ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<div className="revolut-bank__wallet-row">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
<GuosmmNoticesPanel />
|
||||
<details className="revolut-bank__treasury">
|
||||
<summary>
|
||||
Treasury & operator console
|
||||
<NasaIcon name="arrow-right" size={14} className="revolut-bank__chevron" />
|
||||
</summary>
|
||||
<div className="revolut-bank__treasury-body">
|
||||
<CentralBankDashboard />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RevolutTabBar active={tab} onChange={setTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeOfDay(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return 'morning';
|
||||
if (h < 17) return 'afternoon';
|
||||
return 'evening';
|
||||
function LimitRow({ label, used, limit }: { label: string; used: string; limit: string }) {
|
||||
const u = parseFloat(used || '0');
|
||||
const l = parseFloat(limit || '1');
|
||||
const pct = l > 0 ? Math.min(100, (u / l) * 100) : 0;
|
||||
return (
|
||||
<div className="revolut-bank__limit-row">
|
||||
<div className="revolut-bank__limit-head">
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{used} / {limit} USD
|
||||
</span>
|
||||
</div>
|
||||
<div className="revolut-bank__limit-bar">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
324
frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx
Normal file
324
frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NasaIcon from '../../components/icons/NasaIcon';
|
||||
import { formatUsd } from '../../utils/formatMoney';
|
||||
import type { ConsumerAccount, ConsumerCard, ConsumerOverview, ConsumerTransaction } from './useRevolutBank';
|
||||
|
||||
type Props = {
|
||||
overview: ConsumerOverview | null;
|
||||
loading: boolean;
|
||||
balanceHidden: boolean;
|
||||
onToggleBalance: () => void;
|
||||
onGoPayments: () => void;
|
||||
onGoCards: () => void;
|
||||
initials: string;
|
||||
};
|
||||
|
||||
function formatBalance(amount: string, currency: string, hidden: boolean): string {
|
||||
if (hidden) return '••••••';
|
||||
if (currency === 'USD') return formatUsd(amount);
|
||||
return `${parseFloat(amount || '0').toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function txAmount(tx: ConsumerTransaction, hidden: boolean): string {
|
||||
if (hidden) return '••••';
|
||||
const prefix = tx.direction === 'in' ? '+' : '−';
|
||||
const val = parseFloat(tx.amount || '0').toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return `${prefix}${val} ${tx.currency}`;
|
||||
}
|
||||
|
||||
function BalanceSkeleton() {
|
||||
return (
|
||||
<section className="revolut-bank__balance-card" aria-hidden>
|
||||
<div className="revolut-bank__skeleton h-4 w-28 mb-3" />
|
||||
<div className="revolut-bank__skeleton h-12 w-56" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RevolutHomeScreen({
|
||||
overview,
|
||||
loading,
|
||||
balanceHidden,
|
||||
onToggleBalance,
|
||||
onGoPayments,
|
||||
onGoCards,
|
||||
initials,
|
||||
}: Props) {
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
const [selectedAccount, setSelectedAccount] = useState<ConsumerAccount | null>(null);
|
||||
|
||||
const cards = overview?.cards ?? [];
|
||||
const accounts = overview?.accounts.filter((a) =>
|
||||
['current', 'savings'].includes(a.type) || ['EUR', 'GBP'].includes(a.currency),
|
||||
).slice(0, 6) ?? [];
|
||||
const transactions = overview?.transactions ?? [];
|
||||
const displayName = overview?.profile.displayName ?? 'Account';
|
||||
|
||||
const cycleCard = () => {
|
||||
if (cards.length === 0) return;
|
||||
setActiveCard((i) => (i + 1) % cards.length);
|
||||
};
|
||||
|
||||
const visibleCards = cards.length > 0 ? cards : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="revolut-bank__page-header">
|
||||
<button type="button" className="revolut-bank__avatar" aria-label="Profile">
|
||||
{initials}
|
||||
</button>
|
||||
<div className="revolut-bank__header-actions">
|
||||
<span className={`revolut-bank__status${overview?.fineractLive ? '' : ' revolut-bank__status--offline'}`}>
|
||||
{overview?.fineractLive ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
<button type="button" className="revolut-bank__icon-btn" aria-label="Search">
|
||||
<NasaIcon name="search" size={20} />
|
||||
</button>
|
||||
<button type="button" className="revolut-bank__icon-btn" aria-label="Cards" onClick={onGoCards}>
|
||||
<NasaIcon name="money" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{visibleCards.length > 0 && (
|
||||
<section className="revolut-bank__card-stack-wrap" aria-label="Your cards">
|
||||
<div className="revolut-bank__card-stack">
|
||||
{visibleCards.map((card, index) => {
|
||||
const offset = (index - activeCard + visibleCards.length) % visibleCards.length;
|
||||
return (
|
||||
<CardStackItem
|
||||
key={card.cardId}
|
||||
card={card}
|
||||
offset={offset}
|
||||
displayName={displayName}
|
||||
onClick={cycleCard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="revolut-bank__card-dots" aria-hidden>
|
||||
{visibleCards.map((c, i) => (
|
||||
<span key={c.cardId} className={`revolut-bank__card-dot${i === activeCard ? ' revolut-bank__card-dot--active' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading && !overview ? (
|
||||
<BalanceSkeleton />
|
||||
) : (
|
||||
<section className="revolut-bank__balance-card" aria-label="Total balance">
|
||||
<div className="revolut-bank__balance-head">
|
||||
<p className="revolut-bank__balance-label">Total balance</p>
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__balance-toggle"
|
||||
onClick={onToggleBalance}
|
||||
aria-label={balanceHidden ? 'Show balance' : 'Hide balance'}
|
||||
>
|
||||
<NasaIcon name={balanceHidden ? 'eye-off' : 'eye'} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="revolut-bank__balance-amount">
|
||||
{formatBalance(overview?.totalBalance ?? '0', overview?.totalBalanceCurrency ?? 'USD', balanceHidden)}
|
||||
</p>
|
||||
{overview?.profile.plan && (
|
||||
<p className="revolut-bank__balance-account">
|
||||
{overview.profile.plan} · {overview.profile.externalId}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<nav className="revolut-bank__actions" aria-label="Quick actions">
|
||||
<button type="button" className="revolut-bank__action" onClick={onGoPayments}>
|
||||
<span className="revolut-bank__action-icon revolut-bank__action-icon--primary">
|
||||
<NasaIcon name="plus" size={22} />
|
||||
</span>
|
||||
Add money
|
||||
</button>
|
||||
<button type="button" className="revolut-bank__action" onClick={onGoPayments}>
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="money" size={22} />
|
||||
</span>
|
||||
Send
|
||||
</button>
|
||||
<Link to="/swap" className="revolut-bank__action">
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="swap" size={22} />
|
||||
</span>
|
||||
Exchange
|
||||
</Link>
|
||||
<button type="button" className="revolut-bank__action" onClick={onGoCards}>
|
||||
<span className="revolut-bank__action-icon">
|
||||
<NasaIcon name="rail" size={22} />
|
||||
</span>
|
||||
Cards
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section className="revolut-bank__accounts-section" aria-label="Accounts">
|
||||
<div className="revolut-bank__section-title">
|
||||
<h2>Accounts</h2>
|
||||
<button type="button" className="revolut-bank__section-link border-0 bg-transparent cursor-pointer">
|
||||
See all
|
||||
</button>
|
||||
</div>
|
||||
<div className="revolut-bank__accounts">
|
||||
{accounts.map((acct) => (
|
||||
<button
|
||||
key={acct.id}
|
||||
type="button"
|
||||
className={`revolut-bank__account-pill${acct.type === 'current' ? ' revolut-bank__account-pill--active' : ''}`}
|
||||
onClick={() => setSelectedAccount(acct)}
|
||||
>
|
||||
<span className="revolut-bank__account-flag">{acct.flag}</span>
|
||||
<p className="revolut-bank__account-pill-label">{acct.name}</p>
|
||||
<p className="revolut-bank__account-pill-value">
|
||||
{formatBalance(acct.balance, acct.currency, balanceHidden)}
|
||||
</p>
|
||||
<p className="revolut-bank__account-pill-meta">{acct.moneyLayer}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedAccount && (
|
||||
<AccountDetailsSheet account={selectedAccount} onClose={() => setSelectedAccount(null)} />
|
||||
)}
|
||||
|
||||
<section aria-labelledby="activity-heading">
|
||||
<div className="revolut-bank__section-title">
|
||||
<h2 id="activity-heading">Transactions</h2>
|
||||
</div>
|
||||
<div className="revolut-bank__activity revolut-bank__activity--flat">
|
||||
{transactions.length === 0 ? (
|
||||
<div className="revolut-bank__activity-item">
|
||||
<span className="revolut-bank__activity-icon">
|
||||
<NasaIcon name="ledger" size={18} />
|
||||
</span>
|
||||
<div className="revolut-bank__activity-body">
|
||||
<p className="revolut-bank__activity-title">No transactions yet</p>
|
||||
<p className="revolut-bank__activity-meta">Your activity will show up here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
transactions.slice(0, 12).map((item) => (
|
||||
<div key={item.id} className="revolut-bank__activity-item">
|
||||
<span className={`revolut-bank__activity-icon revolut-bank__activity-icon--${item.type}`}>
|
||||
<NasaIcon name={txIcon(item.type)} size={18} />
|
||||
</span>
|
||||
<div className="revolut-bank__activity-body">
|
||||
<p className="revolut-bank__activity-title">{item.title}</p>
|
||||
<p className="revolut-bank__activity-meta">{formatTxMeta(item)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`revolut-bank__activity-amount${
|
||||
item.direction === 'in' ? ' revolut-bank__activity-amount--positive' : ''
|
||||
}${item.status === 'failed' ? ' revolut-bank__activity-amount--failed' : ''}`}
|
||||
>
|
||||
{txAmount(item, balanceHidden)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStackItem({
|
||||
card,
|
||||
offset,
|
||||
displayName,
|
||||
onClick,
|
||||
}: {
|
||||
card: ConsumerCard;
|
||||
offset: number;
|
||||
displayName: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const stackClass = offset <= 2 ? `revolut-bank__virtual-card--stack-${offset}` : 'revolut-bank__virtual-card--stack-2';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`revolut-bank__virtual-card revolut-bank__virtual-card--${card.gradient} ${stackClass}`}
|
||||
onClick={onClick}
|
||||
aria-label={`${card.label} card ending ${card.last4}`}
|
||||
>
|
||||
<div className="revolut-bank__virtual-card-top">
|
||||
<span className="revolut-bank__virtual-card-brand">Z</span>
|
||||
<span className="revolut-bank__virtual-card-type">{card.label}</span>
|
||||
</div>
|
||||
<p className="revolut-bank__virtual-card-number">•••• {card.last4}</p>
|
||||
<p className="revolut-bank__virtual-card-name">{card.cardholderName || displayName}</p>
|
||||
{card.status === 'virtual' && <span className="revolut-bank__virtual-card-badge">Virtual</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountDetailsSheet({ account, onClose }: { account: ConsumerAccount; onClose: () => void }) {
|
||||
const d = account.details;
|
||||
const rows: { label: string; value: string; copy?: boolean }[] = [
|
||||
...(d.iban ? [{ label: 'IBAN', value: d.iban, copy: true }] : []),
|
||||
{ label: 'BIC', value: d.bic, copy: true },
|
||||
{ label: 'Account number', value: d.accountNumber, copy: true },
|
||||
...(d.sortCode ? [{ label: 'Sort code', value: d.sortCode, copy: true }] : []),
|
||||
...(d.routingNumber ? [{ label: 'Routing number', value: d.routingNumber, copy: true }] : []),
|
||||
{ label: 'Bank', value: d.bankName },
|
||||
{ label: 'Address', value: d.bankAddress },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="revolut-bank__sheet-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="revolut-bank__sheet"
|
||||
role="dialog"
|
||||
aria-labelledby="account-details-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="revolut-bank__sheet-handle" aria-hidden />
|
||||
<h2 id="account-details-title" className="revolut-bank__sheet-title">
|
||||
{account.name}
|
||||
</h2>
|
||||
<p className="revolut-bank__sheet-hint">{d.shareText}</p>
|
||||
<dl className="revolut-bank__detail-list">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="revolut-bank__detail-row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>
|
||||
{row.value}
|
||||
{row.copy && (
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__copy-btn"
|
||||
onClick={() => navigator.clipboard?.writeText(row.value.replace(/\s/g, ''))}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<button type="button" className="revolut-bank__submit" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function txIcon(type: string): 'money' | 'swap' | 'check' | 'ledger' {
|
||||
if (type === 'card_payment' || type === 'transfer') return 'money';
|
||||
if (type === 'exchange') return 'swap';
|
||||
if (type === 'card_load' || type === 'deposit') return 'check';
|
||||
return 'ledger';
|
||||
}
|
||||
|
||||
function formatTxMeta(tx: ConsumerTransaction): string {
|
||||
const date = tx.createdAt?.slice(0, 16).replace('T', ' ') ?? '';
|
||||
return `${date} · ${tx.status}`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
onSuccess?: () => void;
|
||||
login: (phone: string, passcode: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export default function RevolutLoginScreen({ onSuccess, login }: Props) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [passcode, setPasscode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(phone.trim(), passcode);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revolut-login">
|
||||
<div className="revolut-login__brand">
|
||||
<div className="revolut-login__logo">Z</div>
|
||||
<h1>Z Online Bank</h1>
|
||||
<p>International portal — secure sign in</p>
|
||||
</div>
|
||||
|
||||
<form className="revolut-login__form" onSubmit={handleSubmit}>
|
||||
<label className="revolut-login__field">
|
||||
<span>Phone number</span>
|
||||
<input
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="+9647700000000"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="revolut-login__field">
|
||||
<span>Passcode</span>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••"
|
||||
value={passcode}
|
||||
onChange={(e) => setPasscode(e.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <p className="revolut-login__error">{error}</p> : null}
|
||||
|
||||
<button type="submit" className="revolut-login__submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="revolut-login__footer">
|
||||
Protected by session encryption. Contact treasury for access.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend-dapp/src/features/online-bank/RevolutTabBar.tsx
Normal file
35
frontend-dapp/src/features/online-bank/RevolutTabBar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import NasaIcon, { type NasaIconName } from '../../components/icons/NasaIcon';
|
||||
|
||||
export type RevolutTab = 'home' | 'invest' | 'payments' | 'hub' | 'more';
|
||||
|
||||
const TABS: { id: RevolutTab; label: string; icon: NasaIconName }[] = [
|
||||
{ id: 'home', label: 'Home', icon: 'central-bank' },
|
||||
{ id: 'invest', label: 'Invest', icon: 'chart' },
|
||||
{ id: 'payments', label: 'Payments', icon: 'money' },
|
||||
{ id: 'hub', label: 'Hub', icon: 'dashboard' },
|
||||
{ id: 'more', label: 'More', icon: 'globe' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
active: RevolutTab;
|
||||
onChange: (tab: RevolutTab) => void;
|
||||
};
|
||||
|
||||
export default function RevolutTabBar({ active, onChange }: Props) {
|
||||
return (
|
||||
<nav className="revolut-tab-bar" aria-label="Revolut navigation">
|
||||
{TABS.map(({ id, label, icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`revolut-tab-bar__item${active === id ? ' revolut-tab-bar__item--active' : ''}`}
|
||||
onClick={() => onChange(id)}
|
||||
aria-current={active === id ? 'page' : undefined}
|
||||
>
|
||||
<NasaIcon name={icon} size={22} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const PHASE_LABEL: Record<SettlementPhase, string> = {
|
||||
HYBX_RAIL_DISPATCHED: 'HYBX dispatched',
|
||||
ISO20022_ARCHIVED: 'ISO 20022 archived',
|
||||
CHAIN_MINT_REQUESTED: 'Chain mint',
|
||||
CHAIN_BRIDGE_REQUESTED: 'Bridge submitted',
|
||||
CHAIN_BRIDGE_PENDING: 'Bridge in flight',
|
||||
SETTLED: 'Settled',
|
||||
FAILED: 'Failed',
|
||||
};
|
||||
@@ -56,7 +58,7 @@ const PROTOCOLS: Record<ProtocolGroup, { id: ProtocolId; label: string }[]> = {
|
||||
digital: [
|
||||
{ id: 'internal', label: 'Internal M2/M3' },
|
||||
{ id: 'token-load', label: 'M2 token load' },
|
||||
{ id: 'fiat-crypto', label: 'Fiat → crypto (138)' },
|
||||
{ id: 'fiat-crypto', label: 'Fiat → crypto' },
|
||||
{ id: 'onex-shiva-crypto', label: 'Fiat → Shiva (OneX)' },
|
||||
{ id: 'external', label: 'M4 SWIFT out' },
|
||||
],
|
||||
@@ -80,6 +82,7 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
|
||||
const [tokenSymbol, setTokenSymbol] = useState('cUSDC');
|
||||
const [tokenAddress, setTokenAddress] = useState('0x71D6687F38b93CCad569Fa6352c876eea967201b');
|
||||
const [lineId, setLineId] = useState('USD-M2');
|
||||
const [deliveryChainId, setDeliveryChainId] = useState<'138' | '1'>('138');
|
||||
|
||||
useEffect(() => {
|
||||
if (address) setRecipient(address);
|
||||
@@ -160,11 +163,12 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
|
||||
case 'token-load':
|
||||
if (!recipient) throw new Error('Connect wallet for mint recipient');
|
||||
await s.tokenLoad({
|
||||
lineId,
|
||||
lineId: deliveryChainId === '1' ? 'WETH-M2' : lineId,
|
||||
symbol: tokenSymbol,
|
||||
tokenAddress,
|
||||
amount: String(amount),
|
||||
recipientAddress: recipient,
|
||||
targetChainId: Number(deliveryChainId),
|
||||
});
|
||||
break;
|
||||
case 'fiat-crypto':
|
||||
@@ -173,7 +177,8 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
|
||||
amount: String(amount),
|
||||
creditorIban: iban,
|
||||
recipientAddress: recipient,
|
||||
tokenLineId: lineId,
|
||||
tokenLineId: deliveryChainId === '1' ? 'WETH-M2' : lineId,
|
||||
targetChainId: Number(deliveryChainId),
|
||||
});
|
||||
break;
|
||||
case 'onex-shiva-crypto':
|
||||
@@ -313,10 +318,23 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
|
||||
</>
|
||||
)}
|
||||
{(active === 'token-load' || active === 'fiat-crypto') && (
|
||||
<label className="revolut-bank__field">
|
||||
<span>OMNL line ID</span>
|
||||
<input type="text" value={lineId} onChange={(e) => setLineId(e.target.value)} className="revolut-bank__input" />
|
||||
</label>
|
||||
<>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Delivery chain</span>
|
||||
<select
|
||||
value={deliveryChainId}
|
||||
onChange={(e) => setDeliveryChainId(e.target.value as '138' | '1')}
|
||||
className="revolut-bank__input"
|
||||
>
|
||||
<option value="138">Chain 138 (OMNL hub)</option>
|
||||
<option value="1">Ethereum Mainnet (138 mint + bridge)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>OMNL line ID</span>
|
||||
<input type="text" value={lineId} onChange={(e) => setLineId(e.target.value)} className="revolut-bank__input" />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -341,6 +359,21 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
|
||||
<strong>Fineract:</strong> {s.lastResult.fineractJournalRef}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.chainTxHash && (
|
||||
<p>
|
||||
<strong>Chain mint:</strong> {s.lastResult.chainTxHash}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.bridgeTxHash && (
|
||||
<p>
|
||||
<strong>Bridge tx:</strong> {s.lastResult.bridgeTxHash}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.bridgeMessageId && (
|
||||
<p>
|
||||
<strong>Bridge message:</strong> {s.lastResult.bridgeMessageId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const PHASE_LABEL: Record<SettlementPhase, string> = {
|
||||
HYBX_RAIL_DISPATCHED: 'HYBX dispatched',
|
||||
ISO20022_ARCHIVED: 'ISO 20022 archived',
|
||||
CHAIN_MINT_REQUESTED: 'Chain mint',
|
||||
CHAIN_BRIDGE_REQUESTED: 'Bridge submitted',
|
||||
CHAIN_BRIDGE_PENDING: 'Bridge in flight',
|
||||
SETTLED: 'Settled',
|
||||
FAILED: 'Failed',
|
||||
};
|
||||
@@ -42,6 +44,7 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
|
||||
const [iban, setIban] = useState(DEFAULT_IBAN);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [includeM3, setIncludeM3] = useState(true);
|
||||
const [deliveryChainId, setDeliveryChainId] = useState<'138' | '1'>('138');
|
||||
|
||||
useEffect(() => {
|
||||
if (address) setRecipient(address);
|
||||
@@ -54,6 +57,7 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
|
||||
amount: String(amount),
|
||||
creditorIban: iban,
|
||||
recipientAddress: includeM3 ? recipient : undefined,
|
||||
targetChainId: includeM3 ? Number(deliveryChainId) : undefined,
|
||||
});
|
||||
await s.refreshSettlements();
|
||||
onSuccess?.();
|
||||
@@ -129,16 +133,29 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
|
||||
<span>Include M3 on-chain mint (M2→M3→M4 path)</span>
|
||||
</label>
|
||||
{includeM3 && (
|
||||
<label className="revolut-bank__field">
|
||||
<span>M3 recipient wallet (Chain 138)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder={isConnected ? address : '0x…'}
|
||||
className="revolut-bank__input"
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Delivery chain</span>
|
||||
<select
|
||||
value={deliveryChainId}
|
||||
onChange={(e) => setDeliveryChainId(e.target.value as '138' | '1')}
|
||||
className="revolut-bank__input"
|
||||
>
|
||||
<option value="138">Chain 138 (OMNL hub)</option>
|
||||
<option value="1">Ethereum Mainnet (138 mint + bridge)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>M3 recipient wallet</span>
|
||||
<input
|
||||
type="text"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder={isConnected ? address : '0x…'}
|
||||
className="revolut-bank__input"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -193,6 +210,21 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
|
||||
<strong>Chain mint:</strong> {s.lastResult.chainTxHash}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.bridgeTxHash && (
|
||||
<p>
|
||||
<strong>Bridge tx:</strong> {s.lastResult.bridgeTxHash}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.bridgeMessageId && (
|
||||
<p>
|
||||
<strong>Bridge message:</strong> {s.lastResult.bridgeMessageId}
|
||||
</p>
|
||||
)}
|
||||
{s.lastResult.destinationChainId && s.lastResult.destinationChainId !== 138 && (
|
||||
<p>
|
||||
<strong>Destination chain:</strong> {s.lastResult.destinationChainId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
282
frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx
Normal file
282
frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState } from 'react';
|
||||
import NasaIcon from '../../components/icons/NasaIcon';
|
||||
import { formatUsd } from '../../utils/formatMoney';
|
||||
import { useZBankPos } from './useZBankPos';
|
||||
|
||||
type Tab = 'pof' | 'load' | 'pos';
|
||||
|
||||
export default function ZBankPosCardsPanel() {
|
||||
const pos = useZBankPos();
|
||||
const [tab, setTab] = useState<Tab>('load');
|
||||
const [amount, setAmount] = useState('1000');
|
||||
const [cardholder, setCardholder] = useState('Z Card holder');
|
||||
const [cardLast4, setCardLast4] = useState('');
|
||||
const [productId, setProductId] = useState('z-prepaid-usd');
|
||||
const [selectedCardId, setSelectedCardId] = useState('');
|
||||
const [terminalId, setTerminalId] = useState('POS-ZBANK-001');
|
||||
const [saleAmount, setSaleAmount] = useState('50');
|
||||
|
||||
const products = pos.config?.cardProducts ?? [
|
||||
{ id: 'z-prepaid-usd', label: 'Z Prepaid USD', minLoad: '10', currency: 'USD' },
|
||||
{ id: 'z-debit-onex', label: 'OneX Debit', minLoad: '100', currency: 'USD' },
|
||||
];
|
||||
const terminals = pos.config?.posTerminals ?? [
|
||||
{ terminalId: 'POS-ZBANK-001', merchantName: 'zBank HQ — Baghdad' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="zbank-pos" id="z-pos-cards" aria-label="zBank proof of funds, cards and POS">
|
||||
<h2 className="revolut-bank__section-title">zBank · Cards & POS</h2>
|
||||
<p className="revolut-bank__protocols-hint">
|
||||
Load prepaid cards from zBank M2 (Office 29), issue proof-of-funds attestations, and run POS sales.
|
||||
</p>
|
||||
|
||||
<div className="zbank-pos__tabs" role="tablist">
|
||||
{(
|
||||
[
|
||||
['pof', 'Proof of funds'],
|
||||
['load', 'Load card'],
|
||||
['pos', 'POS terminal'],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
className={`zbank-pos__tab${tab === id ? ' zbank-pos__tab--active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pos.error && (
|
||||
<div className="revolut-bank__protocol-error" role="alert">
|
||||
{pos.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'pof' && (
|
||||
<div className="zbank-pos__panel" role="tabpanel">
|
||||
<p className="zbank-pos__hint">
|
||||
Fineract GL attestation for zBank (Office {pos.config?.officeId ?? 29}) — M1/M2 + card prepaid pool.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__submit"
|
||||
disabled={pos.loading}
|
||||
onClick={() => pos.fetchProofOfFunds()}
|
||||
>
|
||||
{pos.loading ? 'Generating…' : 'Generate proof of funds'}
|
||||
</button>
|
||||
{pos.pof && (
|
||||
<div className="zbank-pos__pof">
|
||||
<p>
|
||||
<strong>Ref:</strong> {pos.pof.settlementRef}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Primary GL {pos.pof.attestation.primaryGlCode}:</strong>{' '}
|
||||
{formatUsd(pos.pof.attestation.primaryAmountUsd)} {pos.pof.attestation.currency}
|
||||
</p>
|
||||
<p className="zbank-pos__meta">{pos.pof.attestation.methodology}</p>
|
||||
{pos.pof.integrity?.sha256 && (
|
||||
<p className="zbank-pos__sha">
|
||||
SHA-256: <code>{pos.pof.integrity.sha256.slice(0, 24)}…</code>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__section-link border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(pos.pof, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${pos.pof!.settlementRef}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download JSON
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'load' && (
|
||||
<div className="zbank-pos__panel" role="tabpanel">
|
||||
<div className="revolut-bank__protocol-form">
|
||||
<label className="revolut-bank__field">
|
||||
<span>Card product</span>
|
||||
<select
|
||||
className="revolut-bank__input"
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label} (min {p.minLoad} {p.currency})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Load amount (USD)</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="revolut-bank__input"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Cardholder name</span>
|
||||
<input
|
||||
type="text"
|
||||
className="revolut-bank__input"
|
||||
value={cardholder}
|
||||
onChange={(e) => setCardholder(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Last 4 digits (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={4}
|
||||
className="revolut-bank__input"
|
||||
placeholder="Auto"
|
||||
value={cardLast4}
|
||||
onChange={(e) => setCardLast4(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__submit"
|
||||
disabled={pos.loading}
|
||||
onClick={() =>
|
||||
pos.loadCard({
|
||||
cardProductId: productId,
|
||||
amount,
|
||||
cardholderName: cardholder,
|
||||
cardLast4: cardLast4 || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{pos.loading ? 'Loading card…' : `Load ${formatUsd(amount)} from zBank M2`}
|
||||
</button>
|
||||
{pos.lastLoad?.card && (
|
||||
<div className="revolut-bank__protocol-result">
|
||||
<p>
|
||||
<strong>Card:</strong> •••• {(pos.lastLoad.card as { cardLast4: string }).cardLast4} ·{' '}
|
||||
{(pos.lastLoad.card as { cardId: string }).cardId}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Balance:</strong> {formatUsd((pos.lastLoad.card as { balance: string }).balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'pos' && (
|
||||
<div className="zbank-pos__panel" role="tabpanel">
|
||||
<div className="revolut-bank__protocol-form">
|
||||
<label className="revolut-bank__field">
|
||||
<span>Z Card</span>
|
||||
<select
|
||||
className="revolut-bank__input"
|
||||
value={selectedCardId}
|
||||
onChange={(e) => setSelectedCardId(e.target.value)}
|
||||
>
|
||||
<option value="">Select card…</option>
|
||||
{pos.cards.map((c) => (
|
||||
<option key={c.cardId} value={c.cardId}>
|
||||
•••• {c.cardLast4} — {formatUsd(c.balance)} ({c.productLabel})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>POS terminal</span>
|
||||
<select
|
||||
className="revolut-bank__input"
|
||||
value={terminalId}
|
||||
onChange={(e) => setTerminalId(e.target.value)}
|
||||
>
|
||||
{terminals.map((t) => (
|
||||
<option key={t.terminalId} value={t.terminalId}>
|
||||
{t.merchantName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="revolut-bank__field">
|
||||
<span>Sale amount (USD)</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="revolut-bank__input"
|
||||
value={saleAmount}
|
||||
onChange={(e) => setSaleAmount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="revolut-bank__submit zbank-pos__tap"
|
||||
disabled={pos.loading || !selectedCardId}
|
||||
onClick={() =>
|
||||
pos.posSale({
|
||||
cardId: selectedCardId,
|
||||
terminalId,
|
||||
amount: saleAmount,
|
||||
})
|
||||
}
|
||||
>
|
||||
<NasaIcon name="money" size={18} />
|
||||
{pos.loading ? 'Processing…' : `Tap to pay ${formatUsd(saleAmount)}`}
|
||||
</button>
|
||||
{pos.lastSale && (
|
||||
<div className="revolut-bank__protocol-result">
|
||||
<p>
|
||||
<strong>Status:</strong> {String(pos.lastSale.status)}
|
||||
</p>
|
||||
{pos.lastSale.merchantName && (
|
||||
<p>
|
||||
<strong>Merchant:</strong> {String(pos.lastSale.merchantName)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pos.cards.length > 0 && (
|
||||
<div className="zbank-pos__cards">
|
||||
<h3 className="zbank-pos__cards-title">Loaded cards</h3>
|
||||
<ul>
|
||||
{pos.cards.map((c) => (
|
||||
<li key={c.cardId} className="zbank-pos__card-row">
|
||||
<span className="zbank-pos__card-chip" aria-hidden>
|
||||
Z
|
||||
</span>
|
||||
<div>
|
||||
<p className="zbank-pos__card-label">
|
||||
{c.productLabel} · •••• {c.cardLast4}
|
||||
</p>
|
||||
<p className="zbank-pos__card-meta">{c.cardholderName}</p>
|
||||
</div>
|
||||
<span className="zbank-pos__card-balance">{formatUsd(c.balance)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
126
frontend-dapp/src/features/online-bank/useRevolutBank.ts
Normal file
126
frontend-dapp/src/features/online-bank/useRevolutBank.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { settlementApi } from '../../config/dex';
|
||||
import { zBankAuthHeaders } from './useZBankAuth';
|
||||
|
||||
export type ConsumerAccount = {
|
||||
id: string;
|
||||
currency: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
balance: string;
|
||||
availableBalance: string;
|
||||
type: string;
|
||||
moneyLayer: string;
|
||||
details: {
|
||||
iban: string | null;
|
||||
bic: string;
|
||||
accountNumber: string;
|
||||
sortCode?: string | null;
|
||||
routingNumber?: string;
|
||||
bankName: string;
|
||||
bankAddress: string;
|
||||
shareText: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConsumerCard = {
|
||||
cardId: string;
|
||||
tier: string;
|
||||
label: string;
|
||||
gradient: string;
|
||||
network: string;
|
||||
last4: string;
|
||||
cardholderName: string;
|
||||
currency: string;
|
||||
balance: string;
|
||||
status: string;
|
||||
productId: string;
|
||||
expiry?: string;
|
||||
};
|
||||
|
||||
export type ConsumerTransaction = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
direction: 'in' | 'out';
|
||||
status: string;
|
||||
createdAt: string;
|
||||
reference?: string;
|
||||
};
|
||||
|
||||
export type ConsumerOverview = {
|
||||
asOf: string;
|
||||
profile: {
|
||||
displayName: string;
|
||||
plan: string;
|
||||
planTier: string;
|
||||
planPrice: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: {
|
||||
line1: string;
|
||||
city: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
countryName: string;
|
||||
};
|
||||
memberSince: string;
|
||||
kycStatus: string;
|
||||
kycLevel: string;
|
||||
officeId: number;
|
||||
externalId: string;
|
||||
};
|
||||
totalBalance: string;
|
||||
totalBalanceCurrency: string;
|
||||
accounts: ConsumerAccount[];
|
||||
cards: ConsumerCard[];
|
||||
transactions: ConsumerTransaction[];
|
||||
limits: {
|
||||
currency: string;
|
||||
dailyTransferLimit: string;
|
||||
dailyTransferUsed: string;
|
||||
monthlyTransferLimit: string;
|
||||
monthlyTransferUsed: string;
|
||||
cardSpendDailyLimit: string;
|
||||
cardSpendDailyUsed: string;
|
||||
atmDailyLimit: string;
|
||||
atmDailyUsed: string;
|
||||
topUpMonthlyLimit: string;
|
||||
};
|
||||
fineractConnected: boolean;
|
||||
fineractLive: boolean;
|
||||
};
|
||||
|
||||
export function useRevolutBank() {
|
||||
const [overview, setOverview] = useState<ConsumerOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/consumer/overview'), {
|
||||
headers: zBankAuthHeaders(),
|
||||
});
|
||||
const data = (await res.json()) as ConsumerOverview & { error?: string };
|
||||
if (!res.ok) throw new Error(data.error || `Consumer API ${res.status}`);
|
||||
setOverview(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load account');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const t = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [refresh]);
|
||||
|
||||
return { overview, loading, error, refresh };
|
||||
}
|
||||
96
frontend-dapp/src/features/online-bank/useZBankAuth.ts
Normal file
96
frontend-dapp/src/features/online-bank/useZBankAuth.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { settlementApi } from '../../config/dex';
|
||||
|
||||
const SESSION_KEY = 'zbank_session_token';
|
||||
|
||||
export type PortalSession = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export function getZBankSessionToken(): string | null {
|
||||
return localStorage.getItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export function zBankAuthHeaders(): HeadersInit {
|
||||
const token = getZBankSessionToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export function useZBankAuth() {
|
||||
const [requireAuth, setRequireAuth] = useState(false);
|
||||
const [session, setSession] = useState<PortalSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/auth/bootstrap'));
|
||||
const data = (await res.json()) as { requireAuth?: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(data.error || `Bootstrap ${res.status}`);
|
||||
setRequireAuth(Boolean(data.requireAuth));
|
||||
|
||||
const token = getZBankSessionToken();
|
||||
if (!data.requireAuth) {
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
const sRes = await fetch(settlementApi('/z-bank/auth/session'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!sRes.ok) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
const sData = (await sRes.json()) as { session: PortalSession };
|
||||
setSession(sData.session);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Auth bootstrap failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (phone: string, passcode: string) => {
|
||||
setError(null);
|
||||
const res = await fetch(settlementApi('/z-bank/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone, passcode }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; session?: PortalSession; error?: string };
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
if (data.token) localStorage.setItem(SESSION_KEY, data.token);
|
||||
setSession(data.session ?? null);
|
||||
return data.session!;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const token = getZBankSessionToken();
|
||||
if (token) {
|
||||
await fetch(settlementApi('/z-bank/auth/logout'), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
setSession(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const authenticated = !requireAuth || session !== null;
|
||||
|
||||
return { requireAuth, session, loading, error, authenticated, login, logout, refresh: bootstrap };
|
||||
}
|
||||
182
frontend-dapp/src/features/online-bank/useZBankPos.ts
Normal file
182
frontend-dapp/src/features/online-bank/useZBankPos.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { omnlApiHeaders, settlementApi } from '../../config/dex';
|
||||
|
||||
export type ZBankPosConfig = {
|
||||
officeId: number;
|
||||
issuer: { legalName: string; brand: string; guosmmNotice: string };
|
||||
cardProducts: Array<{ id: string; label: string; currency: string; network: string; maxBalance: string; minLoad: string }>;
|
||||
posTerminals: Array<{ terminalId: string; merchantName: string; mcc: string }>;
|
||||
};
|
||||
|
||||
export type ZBankCard = {
|
||||
cardId: string;
|
||||
productId: string;
|
||||
productLabel: string;
|
||||
cardholderName: string;
|
||||
cardLast4: string;
|
||||
currency: string;
|
||||
balance: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ProofOfFundsDoc = {
|
||||
documentType: string;
|
||||
generatedAtUtc: string;
|
||||
settlementRef: string;
|
||||
attestation: { primaryGlCode: string; primaryAmountUsd: string; currency: string; methodology: string };
|
||||
issuer: { legalName: string; brand: string; officeId: number };
|
||||
integrity?: { sha256: string };
|
||||
};
|
||||
|
||||
function idem(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function useZBankPos() {
|
||||
const [config, setConfig] = useState<ZBankPosConfig | null>(null);
|
||||
const [cards, setCards] = useState<ZBankCard[]>([]);
|
||||
const [pof, setPof] = useState<ProofOfFundsDoc | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastSale, setLastSale] = useState<Record<string, unknown> | null>(null);
|
||||
const [lastLoad, setLastLoad] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/pos/config'));
|
||||
if (res.ok) setConfig((await res.json()) as ZBankPosConfig);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshCards = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/consumer/cards'));
|
||||
if (!res.ok) {
|
||||
const authRes = await fetch(settlementApi('/z-bank/pos/cards'), { headers: omnlApiHeaders(false) });
|
||||
if (authRes.ok) {
|
||||
const data = (await authRes.json()) as { cards: ZBankCard[] };
|
||||
setCards(data.cards ?? []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { cards: ZBankCard[] };
|
||||
setCards(
|
||||
(data.cards ?? []).map((c) => ({
|
||||
cardId: c.cardId,
|
||||
productId: c.productId,
|
||||
productLabel: c.label,
|
||||
cardholderName: c.cardholderName,
|
||||
cardLast4: c.last4,
|
||||
currency: c.currency,
|
||||
balance: c.balance,
|
||||
status: c.status,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchProofOfFunds = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/proof-of-funds'));
|
||||
const data = (await res.json()) as ProofOfFundsDoc & { error?: string };
|
||||
if (!res.ok) throw new Error(data.error || `POF failed (${res.status})`);
|
||||
setPof(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Proof of funds failed';
|
||||
setError(msg);
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCard = useCallback(
|
||||
async (body: {
|
||||
cardProductId: string;
|
||||
amount: string;
|
||||
cardholderName?: string;
|
||||
cardLast4?: string;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/pos/card-load'), {
|
||||
method: 'POST',
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: idem('ZBANK-LOAD'),
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as { settlement?: Record<string, unknown>; card?: ZBankCard; error?: string };
|
||||
if (!res.ok) throw new Error(data.error || `Card load failed (${res.status})`);
|
||||
setLastLoad(data);
|
||||
await refreshCards();
|
||||
return data;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Card load failed';
|
||||
setError(msg);
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshCards],
|
||||
);
|
||||
|
||||
const posSale = useCallback(
|
||||
async (body: { cardId: string; terminalId: string; amount: string; merchantReference?: string }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(settlementApi('/z-bank/pos/sale'), {
|
||||
method: 'POST',
|
||||
headers: omnlApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: idem('ZBANK-POS'),
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as Record<string, unknown> & { error?: string; declineReason?: string };
|
||||
if (!res.ok) throw new Error(String(data.declineReason || data.error || `POS declined (${res.status})`));
|
||||
setLastSale(data);
|
||||
await refreshCards();
|
||||
return data;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'POS sale failed';
|
||||
setError(msg);
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshCards],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig();
|
||||
refreshCards();
|
||||
}, [refreshConfig, refreshCards]);
|
||||
|
||||
return {
|
||||
config,
|
||||
cards,
|
||||
pof,
|
||||
loading,
|
||||
error,
|
||||
lastSale,
|
||||
lastLoad,
|
||||
fetchProofOfFunds,
|
||||
loadCard,
|
||||
posSale,
|
||||
refreshCards,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export type SettlementPhase =
|
||||
| 'HYBX_RAIL_DISPATCHED'
|
||||
| 'ISO20022_ARCHIVED'
|
||||
| 'CHAIN_MINT_REQUESTED'
|
||||
| 'CHAIN_BRIDGE_REQUESTED'
|
||||
| 'CHAIN_BRIDGE_PENDING'
|
||||
| 'SETTLED'
|
||||
| 'FAILED';
|
||||
|
||||
@@ -20,6 +22,11 @@ export type SettlementResult = {
|
||||
fineractJournalRef?: string;
|
||||
hybxPaymentId?: string;
|
||||
chainTxHash?: string;
|
||||
mintChainId?: number;
|
||||
destinationChainId?: number;
|
||||
bridgeTxHash?: string;
|
||||
bridgeMessageId?: string;
|
||||
mainnetTxHash?: string;
|
||||
error?: string;
|
||||
layerSteps?: {
|
||||
from: string;
|
||||
@@ -240,11 +247,14 @@ export function useZBankSettlement() {
|
||||
tokenAddress: string;
|
||||
amount: string;
|
||||
recipientAddress: string;
|
||||
targetChainId?: number;
|
||||
}) =>
|
||||
run('M2 token load', async () => {
|
||||
const r = await postJson<SettlementResult>(exchangeApi('/token-load'), {
|
||||
const r = await postJson<SettlementResult>(settlementApi('/token-load'), {
|
||||
idempotencyKey: idem('Z-LOAD'),
|
||||
officeId: 24,
|
||||
currency: 'USD',
|
||||
targetChainId: body.targetChainId ?? 138,
|
||||
...body,
|
||||
});
|
||||
return { ok: r.ok, status: r.status, data: r.data };
|
||||
@@ -290,8 +300,15 @@ export function useZBankSettlement() {
|
||||
);
|
||||
|
||||
const zBankSettleM0ToM4 = useCallback(
|
||||
(body: { amount: string; creditorIban: string; recipientAddress?: string; tokenLineId?: string }) =>
|
||||
(body: {
|
||||
amount: string;
|
||||
creditorIban: string;
|
||||
recipientAddress?: string;
|
||||
tokenLineId?: string;
|
||||
targetChainId?: number;
|
||||
}) =>
|
||||
run('Z Online Bank M0→M4 settlement', async () => {
|
||||
const targetChainId = body.targetChainId ?? 138;
|
||||
const payload = {
|
||||
idempotencyKey: idem('Z-M0M4'),
|
||||
officeId: 24,
|
||||
@@ -301,7 +318,8 @@ export function useZBankSettlement() {
|
||||
beneficiaryName: 'Z Online Bank',
|
||||
remittanceInfo: 'Z Online Bank full-stack settlement M0→M1→M2→M3→M4',
|
||||
recipientAddress: body.recipientAddress,
|
||||
tokenLineId: body.tokenLineId ?? 'USD-M2',
|
||||
tokenLineId: body.tokenLineId ?? (targetChainId === 1 ? 'WETH-M2' : 'USD-M2'),
|
||||
targetChainId,
|
||||
};
|
||||
let r = await postJson<SettlementResult>(settlementApi('/z-bank/settle-m0-m4'), payload);
|
||||
if (r.status === 404 || r.status === 405) {
|
||||
@@ -313,8 +331,8 @@ export function useZBankSettlement() {
|
||||
convertToCrypto: body.recipientAddress
|
||||
? {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
tokenLineId: body.tokenLineId ?? 'USD-M2',
|
||||
targetChainId,
|
||||
tokenLineId: body.tokenLineId ?? (targetChainId === 1 ? 'WETH-M2' : 'USD-M2'),
|
||||
recipientAddress: body.recipientAddress,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
11
packages/settlement-core/dist/bridge-validation.d.ts
vendored
Normal file
11
packages/settlement-core/dist/bridge-validation.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { SettlementBridgeRail } from './types';
|
||||
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
|
||||
export declare const CCIP_WETH9_DESTINATION_CHAIN_IDS: readonly [1];
|
||||
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
|
||||
export declare const CCIP_WETH9_TOKEN_LINES: Set<string>;
|
||||
export declare function resolveBridgeRail(targetChainId: number, tokenLineId: string, explicit?: SettlementBridgeRail): SettlementBridgeRail | null;
|
||||
export declare function isCcipWeth9TokenLine(tokenLineId: string): boolean;
|
||||
export declare function assertBridgeDestinationAllowed(sourceChainId: number, destChainId: number, tokenLineId: string, bridgeRail?: SettlementBridgeRail): {
|
||||
bridgeRail: SettlementBridgeRail;
|
||||
mintChainId: number;
|
||||
};
|
||||
67
packages/settlement-core/dist/bridge-validation.js
vendored
Normal file
67
packages/settlement-core/dist/bridge-validation.js
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CCIP_WETH9_TOKEN_LINES = exports.CCIP_WETH9_DESTINATION_CHAIN_IDS = void 0;
|
||||
exports.resolveBridgeRail = resolveBridgeRail;
|
||||
exports.isCcipWeth9TokenLine = isCcipWeth9TokenLine;
|
||||
exports.assertBridgeDestinationAllowed = assertBridgeDestinationAllowed;
|
||||
const chain_registry_1 = require("./chain-registry");
|
||||
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
|
||||
exports.CCIP_WETH9_DESTINATION_CHAIN_IDS = [1];
|
||||
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
|
||||
exports.CCIP_WETH9_TOKEN_LINES = new Set(['ETH-M2', 'WETH-M2']);
|
||||
const WETH_LINE_SYMBOLS = new Set(['ETH', 'WETH', 'WETH9', 'WETH10']);
|
||||
function resolveBridgeRail(targetChainId, tokenLineId, explicit) {
|
||||
if (targetChainId === 138)
|
||||
return null;
|
||||
if (explicit)
|
||||
return explicit;
|
||||
if (targetChainId === 1 && isCcipWeth9TokenLine(tokenLineId)) {
|
||||
return 'ccip-weth9';
|
||||
}
|
||||
if (targetChainId === 1) {
|
||||
return 'trustless-lockbox';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function isCcipWeth9TokenLine(tokenLineId) {
|
||||
const normalized = tokenLineId.toUpperCase();
|
||||
if (exports.CCIP_WETH9_TOKEN_LINES.has(normalized))
|
||||
return true;
|
||||
const base = normalized.split('-')[0];
|
||||
return WETH_LINE_SYMBOLS.has(base);
|
||||
}
|
||||
function assertBridgeDestinationAllowed(sourceChainId, destChainId, tokenLineId, bridgeRail) {
|
||||
const registry = (0, chain_registry_1.loadOmnlChainRegistry)();
|
||||
if (sourceChainId !== registry.primaryChainId) {
|
||||
throw new Error(`Bridge source must be primary hub chain ${registry.primaryChainId}, got ${sourceChainId}`);
|
||||
}
|
||||
const dest = (0, chain_registry_1.getChainById)(destChainId);
|
||||
if (!dest || dest.status !== 'active') {
|
||||
throw new Error(`Bridge destination chain ${destChainId} is not active in OMNL registry`);
|
||||
}
|
||||
const hub = (0, chain_registry_1.getChainById)(sourceChainId);
|
||||
if (hub?.settlement.bridgeDestinations && !hub.settlement.bridgeDestinations.includes(destChainId)) {
|
||||
throw new Error(`Chain ${destChainId} is not in hub ${sourceChainId} bridgeDestinations`);
|
||||
}
|
||||
if (destChainId === sourceChainId) {
|
||||
throw new Error('Bridge destination must differ from mint chain');
|
||||
}
|
||||
const rail = resolveBridgeRail(destChainId, tokenLineId, bridgeRail);
|
||||
if (!rail) {
|
||||
throw new Error(`No settlement bridge rail configured for chain ${destChainId} and line ${tokenLineId}`);
|
||||
}
|
||||
if (rail === 'ccip-weth9') {
|
||||
if (!exports.CCIP_WETH9_DESTINATION_CHAIN_IDS.includes(destChainId)) {
|
||||
throw new Error(`CCIP WETH9 bridge does not support destination chain ${destChainId}`);
|
||||
}
|
||||
if (!isCcipWeth9TokenLine(tokenLineId)) {
|
||||
throw new Error(`Token line ${tokenLineId} is not eligible for CCIP WETH9 bridge (Phase 1); use trustless-lockbox (Phase 2)`);
|
||||
}
|
||||
}
|
||||
if (rail === 'trustless-lockbox') {
|
||||
if (destChainId !== 1) {
|
||||
throw new Error(`Trustless lockbox bridge only supports destination chain 1, got ${destChainId}`);
|
||||
}
|
||||
}
|
||||
return { bridgeRail: rail, mintChainId: sourceChainId };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type OmnlChainEntry = {
|
||||
m2LoadEnabled: boolean;
|
||||
swapEnabled: boolean;
|
||||
bridgeEnabled: boolean;
|
||||
bridgeDestinations?: number[];
|
||||
officeId: number;
|
||||
};
|
||||
complianceEnv?: string;
|
||||
|
||||
1
packages/settlement-core/dist/index.d.ts
vendored
1
packages/settlement-core/dist/index.d.ts
vendored
@@ -5,3 +5,4 @@ export * from './verbiage';
|
||||
export * from './state-machine';
|
||||
export * from './m2-token-registry';
|
||||
export * from './chain-registry';
|
||||
export * from './bridge-validation';
|
||||
|
||||
1
packages/settlement-core/dist/index.js
vendored
1
packages/settlement-core/dist/index.js
vendored
@@ -21,3 +21,4 @@ __exportStar(require("./verbiage"), exports);
|
||||
__exportStar(require("./state-machine"), exports);
|
||||
__exportStar(require("./m2-token-registry"), exports);
|
||||
__exportStar(require("./chain-registry"), exports);
|
||||
__exportStar(require("./bridge-validation"), exports);
|
||||
|
||||
@@ -10,6 +10,8 @@ const ORDER = [
|
||||
'HYBX_RAIL_DISPATCHED',
|
||||
'ISO20022_ARCHIVED',
|
||||
'CHAIN_MINT_REQUESTED',
|
||||
'CHAIN_BRIDGE_REQUESTED',
|
||||
'CHAIN_BRIDGE_PENDING',
|
||||
'SETTLED',
|
||||
];
|
||||
function nextPhase(current) {
|
||||
|
||||
58
packages/settlement-core/dist/types.d.ts
vendored
58
packages/settlement-core/dist/types.d.ts
vendored
@@ -3,7 +3,8 @@ export type MoneyLayer = 'M0' | 'M1' | 'M2' | 'M3' | 'M4' | 'M00';
|
||||
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138';
|
||||
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';
|
||||
export type SettlementBridgeRail = 'ccip-weth9' | 'trustless-lockbox';
|
||||
export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'CHAIN_BRIDGE_REQUESTED' | 'CHAIN_BRIDGE_PENDING' | 'SETTLED' | 'FAILED';
|
||||
export type ExternalTransferRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId: number;
|
||||
@@ -34,6 +35,7 @@ export type ExternalTransferRequest = {
|
||||
targetChainId: number;
|
||||
tokenLineId: string;
|
||||
recipientAddress: string;
|
||||
bridgeRail?: SettlementBridgeRail;
|
||||
};
|
||||
};
|
||||
export type TokenLoadRequest = {
|
||||
@@ -45,6 +47,7 @@ export type TokenLoadRequest = {
|
||||
amount: string;
|
||||
recipientAddress: string;
|
||||
currency?: string;
|
||||
targetChainId?: number;
|
||||
};
|
||||
export type TransferRequest = {
|
||||
idempotencyKey: string;
|
||||
@@ -83,6 +86,7 @@ export type ZBankM0M4Request = {
|
||||
/** When set, M2→M3 hop posts GL + requests Chain 138 mint */
|
||||
recipientAddress?: string;
|
||||
tokenLineId?: string;
|
||||
targetChainId?: number;
|
||||
};
|
||||
export type SettlementRecord = {
|
||||
settlementId: string;
|
||||
@@ -93,6 +97,11 @@ export type SettlementRecord = {
|
||||
hybxPaymentId?: string;
|
||||
iso20022MessageId?: string;
|
||||
chainTxHash?: string;
|
||||
mintChainId?: number;
|
||||
destinationChainId?: number;
|
||||
bridgeTxHash?: string;
|
||||
bridgeMessageId?: string;
|
||||
mainnetTxHash?: string;
|
||||
verbiageDocument?: string;
|
||||
layerSteps?: ZBankLayerStep[];
|
||||
errors: string[];
|
||||
@@ -132,3 +141,50 @@ export type MoneySupplySnapshot = {
|
||||
inFlightSwift: string;
|
||||
};
|
||||
};
|
||||
export type ZBankCardLoadRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
cardProductId: string;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
cardholderName?: string;
|
||||
/** Last 4 digits for display; full PAN not stored */
|
||||
cardLast4?: string;
|
||||
};
|
||||
export type ZBankPosSaleRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
cardId: string;
|
||||
terminalId: string;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
merchantReference?: string;
|
||||
};
|
||||
export type ZBankPrepaidCard = {
|
||||
cardId: string;
|
||||
productId: string;
|
||||
productLabel: string;
|
||||
officeId: number;
|
||||
cardholderName: string;
|
||||
cardLast4: string;
|
||||
currency: string;
|
||||
balance: string;
|
||||
status: 'active' | 'blocked';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoadAt?: string;
|
||||
lastSaleAt?: string;
|
||||
};
|
||||
export type ZBankPosSaleRecord = {
|
||||
saleId: string;
|
||||
idempotencyKey: string;
|
||||
cardId: string;
|
||||
terminalId: string;
|
||||
merchantName: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
fineractJournalRef?: string;
|
||||
status: 'approved' | 'declined';
|
||||
declineReason?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
4
packages/settlement-core/dist/verbiage.js
vendored
4
packages/settlement-core/dist/verbiage.js
vendored
@@ -33,6 +33,10 @@ function rollExternalTransferVerbiage(req) {
|
||||
}
|
||||
if (req.convertToCrypto?.enabled) {
|
||||
lines.push(`Post-settlement crypto conversion: chain ${req.convertToCrypto.targetChainId} → ${req.convertToCrypto.recipientAddress}`);
|
||||
if (req.convertToCrypto.targetChainId !== 138) {
|
||||
const rail = req.convertToCrypto.bridgeRail ?? 'ccip-weth9';
|
||||
lines.push(`Cross-chain bridge: mint on chain 138 → bridge operator custody → ${rail} → chain ${req.convertToCrypto.targetChainId}`);
|
||||
}
|
||||
}
|
||||
lines.push('', 'DISCLAIMER: Internal OMNL operator verbiage — not SWIFT NET transmission.', 'Fineract SoR + ISO 20022 archive bind settlement finality.');
|
||||
return lines.join('\n');
|
||||
|
||||
39
packages/settlement-core/pnpm-lock.yaml
generated
Normal file
39
packages/settlement-core/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.11.0
|
||||
version: 20.19.43
|
||||
typescript:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@types/node@20.19.43':
|
||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@types/node@20.19.43':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
83
packages/settlement-core/src/bridge-validation.ts
Normal file
83
packages/settlement-core/src/bridge-validation.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { SettlementBridgeRail } from './types';
|
||||
import { getChainById, loadOmnlChainRegistry } from './chain-registry';
|
||||
|
||||
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
|
||||
export const CCIP_WETH9_DESTINATION_CHAIN_IDS = [1] as const;
|
||||
|
||||
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
|
||||
export const CCIP_WETH9_TOKEN_LINES = new Set(['ETH-M2', 'WETH-M2']);
|
||||
|
||||
const WETH_LINE_SYMBOLS = new Set(['ETH', 'WETH', 'WETH9', 'WETH10']);
|
||||
|
||||
export function resolveBridgeRail(
|
||||
targetChainId: number,
|
||||
tokenLineId: string,
|
||||
explicit?: SettlementBridgeRail,
|
||||
): SettlementBridgeRail | null {
|
||||
if (targetChainId === 138) return null;
|
||||
if (explicit) return explicit;
|
||||
if (targetChainId === 1 && isCcipWeth9TokenLine(tokenLineId)) {
|
||||
return 'ccip-weth9';
|
||||
}
|
||||
if (targetChainId === 1) {
|
||||
return 'trustless-lockbox';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCcipWeth9TokenLine(tokenLineId: string): boolean {
|
||||
const normalized = tokenLineId.toUpperCase();
|
||||
if (CCIP_WETH9_TOKEN_LINES.has(normalized)) return true;
|
||||
const base = normalized.split('-')[0];
|
||||
return WETH_LINE_SYMBOLS.has(base);
|
||||
}
|
||||
|
||||
export function assertBridgeDestinationAllowed(
|
||||
sourceChainId: number,
|
||||
destChainId: number,
|
||||
tokenLineId: string,
|
||||
bridgeRail?: SettlementBridgeRail,
|
||||
): { bridgeRail: SettlementBridgeRail; mintChainId: number } {
|
||||
const registry = loadOmnlChainRegistry();
|
||||
if (sourceChainId !== registry.primaryChainId) {
|
||||
throw new Error(`Bridge source must be primary hub chain ${registry.primaryChainId}, got ${sourceChainId}`);
|
||||
}
|
||||
|
||||
const dest = getChainById(destChainId);
|
||||
if (!dest || dest.status !== 'active') {
|
||||
throw new Error(`Bridge destination chain ${destChainId} is not active in OMNL registry`);
|
||||
}
|
||||
|
||||
const hub = getChainById(sourceChainId);
|
||||
if (hub?.settlement.bridgeDestinations && !hub.settlement.bridgeDestinations.includes(destChainId)) {
|
||||
throw new Error(`Chain ${destChainId} is not in hub ${sourceChainId} bridgeDestinations`);
|
||||
}
|
||||
|
||||
if (destChainId === sourceChainId) {
|
||||
throw new Error('Bridge destination must differ from mint chain');
|
||||
}
|
||||
|
||||
const rail = resolveBridgeRail(destChainId, tokenLineId, bridgeRail);
|
||||
if (!rail) {
|
||||
throw new Error(`No settlement bridge rail configured for chain ${destChainId} and line ${tokenLineId}`);
|
||||
}
|
||||
|
||||
if (rail === 'ccip-weth9') {
|
||||
if (!(CCIP_WETH9_DESTINATION_CHAIN_IDS as readonly number[]).includes(destChainId)) {
|
||||
throw new Error(`CCIP WETH9 bridge does not support destination chain ${destChainId}`);
|
||||
}
|
||||
if (!isCcipWeth9TokenLine(tokenLineId)) {
|
||||
throw new Error(
|
||||
`Token line ${tokenLineId} is not eligible for CCIP WETH9 bridge (Phase 1); use trustless-lockbox (Phase 2)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rail === 'trustless-lockbox') {
|
||||
if (destChainId !== 1) {
|
||||
throw new Error(`Trustless lockbox bridge only supports destination chain 1, got ${destChainId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { bridgeRail: rail, mintChainId: sourceChainId };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type OmnlChainEntry = {
|
||||
m2LoadEnabled: boolean;
|
||||
swapEnabled: boolean;
|
||||
bridgeEnabled: boolean;
|
||||
bridgeDestinations?: number[];
|
||||
officeId: number;
|
||||
};
|
||||
complianceEnv?: string;
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './verbiage';
|
||||
export * from './state-machine';
|
||||
export * from './m2-token-registry';
|
||||
export * from './chain-registry';
|
||||
export * from './bridge-validation';
|
||||
|
||||
@@ -8,6 +8,8 @@ const ORDER: SettlementPhase[] = [
|
||||
'HYBX_RAIL_DISPATCHED',
|
||||
'ISO20022_ARCHIVED',
|
||||
'CHAIN_MINT_REQUESTED',
|
||||
'CHAIN_BRIDGE_REQUESTED',
|
||||
'CHAIN_BRIDGE_PENDING',
|
||||
'SETTLED',
|
||||
];
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
|
||||
|
||||
export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910';
|
||||
|
||||
export type SettlementBridgeRail = 'ccip-weth9' | 'trustless-lockbox';
|
||||
|
||||
export type SettlementPhase =
|
||||
| 'RECEIVED'
|
||||
| 'VALIDATED'
|
||||
@@ -24,6 +26,8 @@ export type SettlementPhase =
|
||||
| 'HYBX_RAIL_DISPATCHED'
|
||||
| 'ISO20022_ARCHIVED'
|
||||
| 'CHAIN_MINT_REQUESTED'
|
||||
| 'CHAIN_BRIDGE_REQUESTED'
|
||||
| 'CHAIN_BRIDGE_PENDING'
|
||||
| 'SETTLED'
|
||||
| 'FAILED';
|
||||
|
||||
@@ -57,6 +61,7 @@ export type ExternalTransferRequest = {
|
||||
targetChainId: number;
|
||||
tokenLineId: string;
|
||||
recipientAddress: string;
|
||||
bridgeRail?: SettlementBridgeRail;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -69,6 +74,7 @@ export type TokenLoadRequest = {
|
||||
amount: string;
|
||||
recipientAddress: string;
|
||||
currency?: string;
|
||||
targetChainId?: number;
|
||||
};
|
||||
|
||||
export type TransferRequest = {
|
||||
@@ -110,6 +116,7 @@ export type ZBankM0M4Request = {
|
||||
/** When set, M2→M3 hop posts GL + requests Chain 138 mint */
|
||||
recipientAddress?: string;
|
||||
tokenLineId?: string;
|
||||
targetChainId?: number;
|
||||
};
|
||||
|
||||
export type SettlementRecord = {
|
||||
@@ -121,6 +128,11 @@ export type SettlementRecord = {
|
||||
hybxPaymentId?: string;
|
||||
iso20022MessageId?: string;
|
||||
chainTxHash?: string;
|
||||
mintChainId?: number;
|
||||
destinationChainId?: number;
|
||||
bridgeTxHash?: string;
|
||||
bridgeMessageId?: string;
|
||||
mainnetTxHash?: string;
|
||||
verbiageDocument?: string;
|
||||
layerSteps?: ZBankLayerStep[];
|
||||
errors: string[];
|
||||
@@ -138,3 +150,54 @@ export type MoneySupplySnapshot = {
|
||||
M4: { nearMoney: string; gl1000: string; glContingent: string; inFlightSwift: string };
|
||||
metaFiatRealRail: { pendingSettlement: string; inFlightSwift: string };
|
||||
};
|
||||
|
||||
export type ZBankCardLoadRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
cardProductId: string;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
cardholderName?: string;
|
||||
/** Last 4 digits for display; full PAN not stored */
|
||||
cardLast4?: string;
|
||||
};
|
||||
|
||||
export type ZBankPosSaleRequest = {
|
||||
idempotencyKey: string;
|
||||
officeId?: number;
|
||||
cardId: string;
|
||||
terminalId: string;
|
||||
amount: string;
|
||||
currency?: string;
|
||||
merchantReference?: string;
|
||||
};
|
||||
|
||||
export type ZBankPrepaidCard = {
|
||||
cardId: string;
|
||||
productId: string;
|
||||
productLabel: string;
|
||||
officeId: number;
|
||||
cardholderName: string;
|
||||
cardLast4: string;
|
||||
currency: string;
|
||||
balance: string;
|
||||
status: 'active' | 'blocked';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoadAt?: string;
|
||||
lastSaleAt?: string;
|
||||
};
|
||||
|
||||
export type ZBankPosSaleRecord = {
|
||||
saleId: string;
|
||||
idempotencyKey: string;
|
||||
cardId: string;
|
||||
terminalId: string;
|
||||
merchantName: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
fineractJournalRef?: string;
|
||||
status: 'approved' | 'declined';
|
||||
declineReason?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,12 @@ export function rollExternalTransferVerbiage(req: ExternalTransferRequest): stri
|
||||
lines.push(
|
||||
`Post-settlement crypto conversion: chain ${req.convertToCrypto.targetChainId} → ${req.convertToCrypto.recipientAddress}`,
|
||||
);
|
||||
if (req.convertToCrypto.targetChainId !== 138) {
|
||||
const rail = req.convertToCrypto.bridgeRail ?? 'ccip-weth9';
|
||||
lines.push(
|
||||
`Cross-chain bridge: mint on chain 138 → bridge operator custody → ${rail} → chain ${req.convertToCrypto.targetChainId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
|
||||
@@ -18,7 +18,7 @@ source "$ENV_FILE"
|
||||
set -u
|
||||
set +a
|
||||
|
||||
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|TOKEN_AGGREGATION_URL=|SWIFT_LISTENER_URL=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=)' "$ENV_FILE" || true
|
||||
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|TOKEN_AGGREGATION_URL=|SWIFT_LISTENER_URL=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=|ZBANK_PORTAL_AUTH_CONFIG=|ZBANK_PORTAL_SESSION_SECRET=|ZBANK_PORTAL_PASSCODE=|ZBANK_PORTAL_REQUIRE_AUTH=|ZBANK_CONSUMER_CONFIG=)' "$ENV_FILE" || true
|
||||
|
||||
for key in OMNL_SUPER_ADMIN_OFFICE24_KEY OMNL_SUPER_ADMIN_EXCHANGE_KEY OMNL_SUPER_ADMIN_SECURE_KEY OMNL_SUPER_ADMIN_FOREX_KEY; do
|
||||
val="${!key:-}"
|
||||
|
||||
41
services/settlement-middleware/dist/adapters/bridge.js
vendored
Normal file
41
services/settlement-middleware/dist/adapters/bridge.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requestChainBridge = requestChainBridge;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function requestChainBridge(payload) {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key)
|
||||
headers.Authorization = `Bearer ${key}`;
|
||||
try {
|
||||
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/settlement/bridge-to-chain`, {
|
||||
settlementRef: payload.settlementRef,
|
||||
sourceChainId: 138,
|
||||
destinationChainId: payload.destinationChainId,
|
||||
tokenAddress: payload.tokenAddress,
|
||||
amount: payload.amount,
|
||||
recipient: payload.recipient,
|
||||
bridgeRail: payload.bridgeRail,
|
||||
dryRun: payload.dryRun,
|
||||
decimals: payload.decimals,
|
||||
wrapNative: payload.wrapNative,
|
||||
}, { headers, timeout: 180000 });
|
||||
return {
|
||||
bridgeTxHash: data.bridgeTxHash,
|
||||
messageId: data.messageId,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'CHAIN_BRIDGE_PENDING')),
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_BRIDGE_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.forwardSwiftToListener = forwardSwiftToListener;
|
||||
exports.buildMt103Stub = buildMt103Stub;
|
||||
exports.buildMt102Stub = buildMt102Stub;
|
||||
exports.buildMt202Stub = buildMt202Stub;
|
||||
exports.buildMt910Stub = buildMt910Stub;
|
||||
const axios_1 = __importDefault(require("axios"));
|
||||
async function forwardSwiftToListener(raw) {
|
||||
const url = (process.env.SWIFT_LISTENER_URL || 'http://192.168.11.114:8788').replace(/\/$/, '');
|
||||
@@ -40,3 +42,25 @@ ${fields.beneficiary}
|
||||
:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
/** MT202 — interbank cover / institution transfer (SBLC correspondent leg). */
|
||||
function buildMt202Stub(fields) {
|
||||
const ord = fields.orderingInstitution ? `:52A:${fields.orderingInstitution}\n` : '';
|
||||
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
|
||||
const ben = fields.beneficiaryInstitution ? `:58A:${fields.beneficiaryInstitution}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I202BANKUS33XXXXN}{4:
|
||||
:20:${fields.senderRef}
|
||||
:21:${fields.relatedRef}
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
${ord}${acct}${ben}:70:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
/** MT910 — confirmation of credit (advice to beneficiary bank). */
|
||||
function buildMt910Stub(fields) {
|
||||
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
|
||||
return `{1:F01OMNLUS33XXXX0000000000}{2:I910BANKUS33XXXXN}{4:
|
||||
:20:${fields.transactionRef}
|
||||
:21:${fields.relatedRef}
|
||||
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
|
||||
${acct}:72:${fields.remittance}
|
||||
-}`;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,37 @@ const office_scope_1 = require("../../workflows/office-scope");
|
||||
const settlement_store_1 = require("../../store/settlement-store");
|
||||
const swift_inbound_1 = require("../../workflows/swift-inbound");
|
||||
const z_bank_m0_m4_1 = require("../../workflows/z-bank-m0-m4");
|
||||
const zbank_proof_of_funds_1 = require("../../workflows/zbank-proof-of-funds");
|
||||
const zbank_card_load_1 = require("../../workflows/zbank-card-load");
|
||||
const zbank_pos_sale_1 = require("../../workflows/zbank-pos-sale");
|
||||
const zbank_pos_config_1 = require("../../services/zbank-pos-config");
|
||||
const zbank_card_store_1 = require("../../store/zbank-card-store");
|
||||
const fineract_1 = require("../../adapters/fineract");
|
||||
const zbank_consumer_banking_1 = require("../../services/zbank-consumer-banking");
|
||||
const zbank_consumer_config_1 = require("../../services/zbank-consumer-config");
|
||||
const zbank_portal_auth_1 = require("../../services/zbank-portal-auth");
|
||||
const auth_1 = require("../../middleware/auth");
|
||||
function onlineBankEnabled() {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
return cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
}
|
||||
function allowOnlineBankPublic(req, res) {
|
||||
if (onlineBankEnabled())
|
||||
return true;
|
||||
return (0, auth_1.requireApiKey)(req, res);
|
||||
}
|
||||
function allowZBankConsumer(req, res) {
|
||||
if (!onlineBankEnabled())
|
||||
return (0, auth_1.requireApiKey)(req, res);
|
||||
if (!(0, zbank_portal_auth_1.portalAuthRequired)())
|
||||
return true;
|
||||
const session = (0, zbank_portal_auth_1.verifySession)((0, zbank_portal_auth_1.extractSessionToken)(req.headers.authorization));
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Portal login required' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function respondPublicMoneySupply(res, officeId) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
@@ -233,6 +262,7 @@ function createSettlementRouter() {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
// convertToCrypto.targetChainId: 138 (default) or 1 (mint on 138 + CCIP/trustless bridge to Ethereum)
|
||||
const body = req.body;
|
||||
const merged = (0, office_scope_1.bindOffice24)({
|
||||
...body,
|
||||
@@ -351,5 +381,209 @@ function createSettlementRouter() {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/auth/bootstrap', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res))
|
||||
return;
|
||||
try {
|
||||
const cfg = (0, zbank_portal_auth_1.loadZBankPortalAuthConfig)();
|
||||
res.json({
|
||||
requireAuth: (0, zbank_portal_auth_1.portalAuthRequired)(),
|
||||
users: cfg.users.map((u) => ({ phone: u.phone, displayName: u.displayName })),
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/z-bank/auth/login', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res))
|
||||
return;
|
||||
const phone = String(req.body?.phone ?? '').trim();
|
||||
const passcode = String(req.body?.passcode ?? '');
|
||||
if (!phone || !passcode) {
|
||||
res.status(400).json({ error: 'Phone and passcode are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { token, session } = (0, zbank_portal_auth_1.loginWithPasscode)(phone, passcode);
|
||||
res.json({
|
||||
token,
|
||||
session: {
|
||||
userId: session.userId,
|
||||
displayName: session.displayName,
|
||||
phone: session.phone,
|
||||
email: session.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(401).json({ error: e instanceof Error ? e.message : 'Login failed' });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/auth/session', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res))
|
||||
return;
|
||||
const session = (0, zbank_portal_auth_1.verifySession)((0, zbank_portal_auth_1.extractSessionToken)(req.headers.authorization));
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Invalid or expired session' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
session: {
|
||||
userId: session.userId,
|
||||
displayName: session.displayName,
|
||||
phone: session.phone,
|
||||
email: session.email,
|
||||
},
|
||||
});
|
||||
});
|
||||
router.post('/z-bank/auth/logout', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res))
|
||||
return;
|
||||
res.json({ ok: true });
|
||||
});
|
||||
router.get('/z-bank/consumer/overview', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
try {
|
||||
res.json(await (0, zbank_consumer_banking_1.buildConsumerOverview)());
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/consumer/profile', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
try {
|
||||
const overview = await (0, zbank_consumer_banking_1.buildConsumerOverview)();
|
||||
res.json({ profile: overview.profile, limits: overview.limits });
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/consumer/accounts', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
try {
|
||||
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const { accounts, totalBalance, fineractConnected, fineractLive } = await (0, zbank_consumer_banking_1.buildConsumerAccounts)(posCfg.officeId);
|
||||
res.json({ accounts, totalBalance, totalBalanceCurrency: 'USD', fineractConnected, fineractLive });
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/consumer/cards', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
(0, zbank_consumer_banking_1.buildConsumerOverview)()
|
||||
.then((o) => res.json({ cards: o.cards }))
|
||||
.catch((e) => res.status(500).json({ error: e instanceof Error ? e.message : String(e) }));
|
||||
});
|
||||
router.get('/z-bank/consumer/transactions', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
const limit = Number(req.query.limit ?? 50);
|
||||
res.json({ transactions: (0, zbank_consumer_banking_1.buildConsumerTransactions)(limit) });
|
||||
});
|
||||
router.get('/z-bank/consumer/limits', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
res.json((0, zbank_consumer_config_1.loadZBankConsumerConfig)().limits);
|
||||
});
|
||||
router.get('/z-bank/consumer/accounts/:accountId', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
try {
|
||||
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const { accounts } = await (0, zbank_consumer_banking_1.buildConsumerAccounts)(posCfg.officeId);
|
||||
const account = accounts.find((a) => a.id === req.params.accountId);
|
||||
if (!account) {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
return;
|
||||
}
|
||||
res.json(account);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/proof-of-funds', async (req, res) => {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const allowPublic = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
if (!allowPublic && !(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const officeRaw = String(req.query.officeId ?? '').trim();
|
||||
const officeId = officeRaw ? parseInt(officeRaw, 10) : (0, zbank_pos_config_1.loadZBankPosConfig)().officeId;
|
||||
res.json(await (0, zbank_proof_of_funds_1.buildZBankProofOfFunds)(officeId));
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/pos/config', (_req, res) => {
|
||||
try {
|
||||
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
res.json({
|
||||
officeId: cfg.officeId,
|
||||
issuer: cfg.issuer,
|
||||
cardProducts: cfg.cardProducts,
|
||||
posTerminals: cfg.posTerminals,
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.get('/z-bank/pos/cards', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res))
|
||||
return;
|
||||
res.json({ cards: zbank_card_store_1.zbankCardStore.listCards(Number(req.query.limit ?? 50)) });
|
||||
});
|
||||
router.get('/z-bank/pos/cards/:cardId', (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
const card = zbank_card_store_1.zbankCardStore.getCard(req.params.cardId);
|
||||
if (!card) {
|
||||
res.status(404).json({ error: 'Card not found' });
|
||||
return;
|
||||
}
|
||||
res.json(card);
|
||||
});
|
||||
router.post('/z-bank/pos/card-load', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey || !body.cardProductId || !body.amount) {
|
||||
res.status(400).json({ error: 'idempotencyKey, cardProductId, amount required' });
|
||||
return;
|
||||
}
|
||||
const out = await (0, zbank_card_load_1.processZBankCardLoad)(body);
|
||||
res.status(out.settlement.phase === 'FAILED' ? 422 : 200).json(out);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
router.post('/z-bank/pos/sale', async (req, res) => {
|
||||
if (!(0, auth_1.requireApiKey)(req, res))
|
||||
return;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.idempotencyKey || !body.cardId || !body.terminalId || !body.amount) {
|
||||
res.status(400).json({ error: 'idempotencyKey, cardId, terminalId, amount required' });
|
||||
return;
|
||||
}
|
||||
const sale = await (0, zbank_pos_sale_1.processZBankPosSale)(body);
|
||||
res.status(sale?.status === 'declined' ? 422 : 200).json(sale);
|
||||
}
|
||||
catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ function loadConfig() {
|
||||
const configPath = process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
|
||||
repoConfigPath('config/settlement-middleware.v1.json');
|
||||
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
||||
if (cached.production.allowChainBridgeExecute === undefined) {
|
||||
cached.production.allowChainBridgeExecute = false;
|
||||
}
|
||||
if (process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1') {
|
||||
cached.production.allowChainBridgeExecute = true;
|
||||
}
|
||||
if (process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1') {
|
||||
cached.production.allowChainMintExecute = true;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
function loadOfficeProfile() {
|
||||
|
||||
17
services/settlement-middleware/dist/index.js
vendored
17
services/settlement-middleware/dist/index.js
vendored
@@ -40,8 +40,19 @@ const dotenv = __importStar(require("dotenv"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = require("fs");
|
||||
const server_1 = require("./server");
|
||||
const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
|
||||
if ((0, fs_1.existsSync)(rootEnv))
|
||||
dotenv.config({ path: rootEnv });
|
||||
const envCandidates = [
|
||||
path_1.default.resolve(__dirname, '../../../.env'),
|
||||
path_1.default.resolve(__dirname, '../../.env'),
|
||||
path_1.default.resolve(__dirname, '../.env'),
|
||||
];
|
||||
for (const envPath of envCandidates) {
|
||||
if ((0, fs_1.existsSync)(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
}
|
||||
const serviceEnv = path_1.default.resolve(__dirname, '../.env');
|
||||
if ((0, fs_1.existsSync)(serviceEnv)) {
|
||||
dotenv.config({ path: serviceEnv, override: true });
|
||||
}
|
||||
dotenv.config();
|
||||
(0, server_1.startServer)();
|
||||
|
||||
263
services/settlement-middleware/dist/services/zbank-consumer-banking.js
vendored
Normal file
263
services/settlement-middleware/dist/services/zbank-consumer-banking.js
vendored
Normal file
@@ -0,0 +1,263 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildConsumerTransactions = buildConsumerTransactions;
|
||||
exports.buildConsumerAccounts = buildConsumerAccounts;
|
||||
exports.buildConsumerOverview = buildConsumerOverview;
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const config_1 = require("../config");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const zbank_card_store_1 = require("../store/zbank-card-store");
|
||||
const zbank_pos_config_1 = require("./zbank-pos-config");
|
||||
const zbank_consumer_config_1 = require("./zbank-consumer-config");
|
||||
const ACCOUNT_FLAGS = {
|
||||
USD: '🇺🇸',
|
||||
EUR: '🇪🇺',
|
||||
GBP: '🇬🇧',
|
||||
SAVINGS: '💰',
|
||||
RESERVE: '🏦',
|
||||
CRYPTO: '⛓',
|
||||
};
|
||||
function parseTs(iso) {
|
||||
return iso ? Date.parse(iso) || 0 : 0;
|
||||
}
|
||||
function settlementToTransaction(r) {
|
||||
const req = r.request;
|
||||
const amount = req.amount ?? '0';
|
||||
const currency = req.currency ?? 'USD';
|
||||
const failed = r.phase === 'FAILED';
|
||||
const pending = !failed && r.phase !== 'SETTLED';
|
||||
const remittance = req.remittanceInfo ?? req.beneficiaryName ?? r.settlementId;
|
||||
const outbound = Boolean(req.creditorIban || req.beneficiaryName);
|
||||
let type = 'settlement';
|
||||
if (remittance.toLowerCase().includes('card load'))
|
||||
type = 'card_load';
|
||||
else if (req.rail?.includes('SWIFT') || req.creditorIban)
|
||||
type = 'transfer';
|
||||
else if (remittance.toLowerCase().includes('swap') || remittance.toLowerCase().includes('exchange')) {
|
||||
type = 'exchange';
|
||||
}
|
||||
return {
|
||||
id: r.settlementId,
|
||||
type,
|
||||
title: remittance.slice(0, 64) || 'Settlement',
|
||||
subtitle: `${req.rail ?? 'OMNL'} · ${r.phase}`,
|
||||
amount,
|
||||
currency,
|
||||
direction: type === 'card_load' ? 'in' : outbound ? 'out' : 'in',
|
||||
status: failed ? 'failed' : pending ? 'pending' : 'completed',
|
||||
createdAt: r.updatedAt ?? r.createdAt,
|
||||
reference: r.fineractJournalRef ?? r.hybxPaymentId,
|
||||
};
|
||||
}
|
||||
function saleToTransaction(s) {
|
||||
return {
|
||||
id: s.saleId,
|
||||
type: 'card_payment',
|
||||
title: s.merchantName,
|
||||
subtitle: `Card •••• ${s.cardId.slice(-4)} · ${s.terminalId}`,
|
||||
amount: s.amount,
|
||||
currency: s.currency,
|
||||
direction: 'out',
|
||||
status: s.status === 'approved' ? 'completed' : 'failed',
|
||||
createdAt: s.createdAt,
|
||||
reference: s.fineractJournalRef,
|
||||
};
|
||||
}
|
||||
function cardToDisplay(card, cfg) {
|
||||
const tierMeta = cfg.cardTierMap[card.productId] ?? {
|
||||
tier: 'standard',
|
||||
label: card.productLabel,
|
||||
gradient: 'standard',
|
||||
network: 'Visa',
|
||||
};
|
||||
return {
|
||||
cardId: card.cardId,
|
||||
tier: tierMeta.tier,
|
||||
label: tierMeta.label,
|
||||
gradient: tierMeta.gradient,
|
||||
network: tierMeta.network,
|
||||
last4: card.cardLast4,
|
||||
cardholderName: card.cardholderName,
|
||||
currency: card.currency,
|
||||
balance: card.balance,
|
||||
status: card.status === 'blocked' ? 'frozen' : 'active',
|
||||
productId: card.productId,
|
||||
expiry: '12/28',
|
||||
};
|
||||
}
|
||||
function virtualCards(cfg, holder) {
|
||||
return cfg.virtualCardProducts.map((p, i) => ({
|
||||
cardId: `virtual-${p.productId}`,
|
||||
tier: p.tier,
|
||||
label: p.label,
|
||||
gradient: p.gradient,
|
||||
network: p.network,
|
||||
last4: String(9000 + i * 111).slice(-4),
|
||||
cardholderName: holder,
|
||||
currency: 'USD',
|
||||
balance: '0.00',
|
||||
status: 'virtual',
|
||||
productId: p.productId,
|
||||
expiry: '12/28',
|
||||
}));
|
||||
}
|
||||
function buildConsumerTransactions(limit = 50) {
|
||||
const settlements = settlement_store_1.settlementStore
|
||||
.list(200)
|
||||
.map(settlementToTransaction)
|
||||
.filter(Boolean);
|
||||
const sales = zbank_card_store_1.zbankCardStore.listSales(100).map(saleToTransaction);
|
||||
return [...settlements, ...sales]
|
||||
.sort((a, b) => parseTs(b.createdAt) - parseTs(a.createdAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
async function buildConsumerAccounts(officeId) {
|
||||
const cfg = (0, zbank_consumer_config_1.loadZBankConsumerConfig)();
|
||||
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const fineractConnected = await (0, fineract_1.fineractOfficeReachable)(officeId);
|
||||
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
|
||||
const fineractLive = fineractConnected && hasActivity;
|
||||
const m1 = snap.M1.circulating ?? snap.M1.gl2100 ?? '0';
|
||||
const m2 = snap.M2.broadMoney ?? snap.M2.gl2200 ?? '0';
|
||||
const m0 = snap.M0.gl1050 ?? '0';
|
||||
const m3 = snap.M3.tokenLiabilities ?? snap.M3.gl2300 ?? '0';
|
||||
const m4 = snap.M4.nearMoney ?? snap.M4.gl1000 ?? '0';
|
||||
const cardPool = balances[posCfg.gl.cardPrepaidLiability] ?? '0';
|
||||
const usdDetails = cfg.accountDetails.USD;
|
||||
const eurDetails = cfg.accountDetails.EUR;
|
||||
const gbpDetails = cfg.accountDetails.GBP;
|
||||
const accounts = [
|
||||
{
|
||||
id: 'usd-main',
|
||||
currency: 'USD',
|
||||
name: 'Main · USD',
|
||||
flag: ACCOUNT_FLAGS.USD,
|
||||
balance: m1,
|
||||
availableBalance: m1,
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: usdDetails,
|
||||
},
|
||||
{
|
||||
id: 'usd-savings',
|
||||
currency: 'USD',
|
||||
name: 'Savings',
|
||||
flag: ACCOUNT_FLAGS.SAVINGS,
|
||||
balance: m2,
|
||||
availableBalance: m2,
|
||||
type: 'savings',
|
||||
moneyLayer: 'M2',
|
||||
details: { ...usdDetails, accountName: 'USD Savings (M2 tokenized)' },
|
||||
},
|
||||
{
|
||||
id: 'eur',
|
||||
currency: 'EUR',
|
||||
name: 'Euro',
|
||||
flag: ACCOUNT_FLAGS.EUR,
|
||||
balance: '0.00',
|
||||
availableBalance: '0.00',
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: eurDetails,
|
||||
},
|
||||
{
|
||||
id: 'gbp',
|
||||
currency: 'GBP',
|
||||
name: 'British Pound',
|
||||
flag: ACCOUNT_FLAGS.GBP,
|
||||
balance: '0.00',
|
||||
availableBalance: '0.00',
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: gbpDetails,
|
||||
},
|
||||
{
|
||||
id: 'usd-cards',
|
||||
currency: 'USD',
|
||||
name: 'Card balance',
|
||||
flag: '💳',
|
||||
balance: cardPool,
|
||||
availableBalance: cardPool,
|
||||
type: 'current',
|
||||
moneyLayer: 'M2',
|
||||
details: { ...usdDetails, accountName: 'Prepaid card pool (GL 2210)' },
|
||||
},
|
||||
{
|
||||
id: 'reserve',
|
||||
currency: 'USD',
|
||||
name: 'Reserve',
|
||||
flag: ACCOUNT_FLAGS.RESERVE,
|
||||
balance: m0,
|
||||
availableBalance: m0,
|
||||
type: 'reserve',
|
||||
moneyLayer: 'M0',
|
||||
details: { ...usdDetails, accountName: 'M0 base money reserve' },
|
||||
},
|
||||
{
|
||||
id: 'crypto',
|
||||
currency: 'USD',
|
||||
name: 'On-chain',
|
||||
flag: ACCOUNT_FLAGS.CRYPTO,
|
||||
balance: m3,
|
||||
availableBalance: m3,
|
||||
type: 'crypto',
|
||||
moneyLayer: 'M3',
|
||||
details: { ...usdDetails, accountName: 'M3 digital / on-chain' },
|
||||
},
|
||||
{
|
||||
id: 'swift',
|
||||
currency: 'USD',
|
||||
name: 'SWIFT rails',
|
||||
flag: '🌐',
|
||||
balance: m4,
|
||||
availableBalance: m4,
|
||||
type: 'fiat_rail',
|
||||
moneyLayer: 'M4',
|
||||
details: { ...usdDetails, accountName: 'M4 SWIFT settlement' },
|
||||
},
|
||||
];
|
||||
const total = (parseFloat(m1 || '0') +
|
||||
parseFloat(m2 || '0') +
|
||||
parseFloat(cardPool || '0')).toFixed(2);
|
||||
return { accounts, totalBalance: total, fineractConnected, fineractLive };
|
||||
}
|
||||
async function buildConsumerOverview() {
|
||||
const cfg = (0, zbank_consumer_config_1.loadZBankConsumerConfig)();
|
||||
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const office = (0, config_1.loadOfficeProfile)();
|
||||
const holder = office.name?.split('—')[0]?.trim() ?? 'Z Bank Customer';
|
||||
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(posCfg.officeId);
|
||||
const storedCards = zbank_card_store_1.zbankCardStore.listCards(20);
|
||||
const cards = storedCards.length > 0
|
||||
? storedCards.map((c) => cardToDisplay(c, cfg))
|
||||
: virtualCards(cfg, holder);
|
||||
const transactions = buildConsumerTransactions(30);
|
||||
return {
|
||||
asOf: new Date().toISOString(),
|
||||
profile: {
|
||||
displayName: holder,
|
||||
plan: cfg.profile.plan,
|
||||
planTier: cfg.profile.planTier,
|
||||
planPrice: cfg.profile.planPrice,
|
||||
email: cfg.profile.email,
|
||||
phone: cfg.profile.phone,
|
||||
address: cfg.profile.address,
|
||||
memberSince: cfg.profile.memberSince,
|
||||
kycStatus: cfg.profile.kycStatus,
|
||||
kycLevel: cfg.profile.kycLevel,
|
||||
officeId: posCfg.officeId,
|
||||
externalId: posCfg.externalId,
|
||||
},
|
||||
totalBalance,
|
||||
totalBalanceCurrency: 'USD',
|
||||
accounts,
|
||||
cards,
|
||||
transactions,
|
||||
limits: cfg.limits,
|
||||
fineractConnected,
|
||||
fineractLive,
|
||||
};
|
||||
}
|
||||
20
services/settlement-middleware/dist/services/zbank-consumer-config.js
vendored
Normal file
20
services/settlement-middleware/dist/services/zbank-consumer-config.js
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadZBankConsumerConfig = loadZBankConsumerConfig;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
let cached = null;
|
||||
function repoConfigPath(relative) {
|
||||
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
function loadZBankConsumerConfig() {
|
||||
if (cached)
|
||||
return cached;
|
||||
const configPath = process.env.ZBANK_CONSUMER_CONFIG?.trim() ||
|
||||
repoConfigPath('config/zbank-consumer-account.v1.json');
|
||||
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
||||
return cached;
|
||||
}
|
||||
145
services/settlement-middleware/dist/services/zbank-portal-auth.js
vendored
Normal file
145
services/settlement-middleware/dist/services/zbank-portal-auth.js
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadZBankPortalAuthConfig = loadZBankPortalAuthConfig;
|
||||
exports.issueSession = issueSession;
|
||||
exports.verifySession = verifySession;
|
||||
exports.extractSessionToken = extractSessionToken;
|
||||
exports.loginWithPasscode = loginWithPasscode;
|
||||
exports.portalAuthRequired = portalAuthRequired;
|
||||
exports.generateSessionSecret = generateSessionSecret;
|
||||
const crypto_1 = require("crypto");
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const attemptLog = new Map();
|
||||
let cachedConfig = null;
|
||||
function repoConfigPath(relative) {
|
||||
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
function loadZBankPortalAuthConfig() {
|
||||
if (cachedConfig)
|
||||
return cachedConfig;
|
||||
const configPath = process.env.ZBANK_PORTAL_AUTH_CONFIG?.trim() ||
|
||||
repoConfigPath('config/zbank-portal-auth.v1.json');
|
||||
cachedConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
||||
return cachedConfig;
|
||||
}
|
||||
function sessionSecret() {
|
||||
const secret = process.env.ZBANK_PORTAL_SESSION_SECRET?.trim();
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error('ZBANK_PORTAL_SESSION_SECRET must be set (min 16 chars) for portal login');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
function configuredPasscode() {
|
||||
const code = process.env.ZBANK_PORTAL_PASSCODE?.trim();
|
||||
if (!code)
|
||||
throw new Error('ZBANK_PORTAL_PASSCODE is not configured');
|
||||
return code;
|
||||
}
|
||||
function normalizePhone(input) {
|
||||
return input.replace(/[\s-]/g, '');
|
||||
}
|
||||
function signPayload(payload) {
|
||||
return (0, crypto_1.createHmac)('sha256', sessionSecret()).update(payload).digest('base64url');
|
||||
}
|
||||
function issueSession(user) {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body = {
|
||||
userId: user.id,
|
||||
displayName: user.displayName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
iat: now,
|
||||
exp: now + cfg.sessionTtlHours * 3600,
|
||||
};
|
||||
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
|
||||
return `${payload}.${signPayload(payload)}`;
|
||||
}
|
||||
function verifySession(token) {
|
||||
if (!token?.includes('.'))
|
||||
return null;
|
||||
const [payload, sig] = token.split('.', 2);
|
||||
if (!payload || !sig)
|
||||
return null;
|
||||
const expected = signPayload(payload);
|
||||
try {
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !(0, crypto_1.timingSafeEqual)(a, b))
|
||||
return null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
if (!session.exp || session.exp < Math.floor(Date.now() / 1000))
|
||||
return null;
|
||||
return session;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function extractSessionToken(authHeader) {
|
||||
if (!authHeader)
|
||||
return undefined;
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return m?.[1]?.trim();
|
||||
}
|
||||
function secureCompare(a, b) {
|
||||
const ha = (0, crypto_1.createHash)('sha256').update(a).digest();
|
||||
const hb = (0, crypto_1.createHash)('sha256').update(b).digest();
|
||||
return (0, crypto_1.timingSafeEqual)(ha, hb);
|
||||
}
|
||||
function checkLockout(key) {
|
||||
const entry = attemptLog.get(key);
|
||||
if (entry?.lockedUntil && entry.lockedUntil > Date.now()) {
|
||||
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
|
||||
return `Too many attempts. Try again in ${mins} minute(s).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function recordFailedAttempt(key) {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const entry = attemptLog.get(key) ?? { count: 0 };
|
||||
entry.count += 1;
|
||||
if (entry.count >= cfg.maxAttempts) {
|
||||
entry.lockedUntil = Date.now() + cfg.lockoutMinutes * 60_000;
|
||||
entry.count = 0;
|
||||
}
|
||||
attemptLog.set(key, entry);
|
||||
}
|
||||
function loginWithPasscode(phone, passcode) {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const normalized = normalizePhone(phone);
|
||||
const lockKey = (0, crypto_1.createHash)('sha256').update(normalized).digest('hex').slice(0, 16);
|
||||
const locked = checkLockout(lockKey);
|
||||
if (locked)
|
||||
throw new Error(locked);
|
||||
const user = cfg.users.find((u) => normalizePhone(u.phone) === normalized);
|
||||
if (!user) {
|
||||
recordFailedAttempt(lockKey);
|
||||
throw new Error('Invalid phone number or passcode');
|
||||
}
|
||||
if (!secureCompare(passcode, configuredPasscode())) {
|
||||
recordFailedAttempt(lockKey);
|
||||
throw new Error('Invalid phone number or passcode');
|
||||
}
|
||||
attemptLog.delete(lockKey);
|
||||
const token = issueSession(user);
|
||||
const session = verifySession(token);
|
||||
if (!session)
|
||||
throw new Error('Failed to create session');
|
||||
return { token, session };
|
||||
}
|
||||
function portalAuthRequired() {
|
||||
return process.env.ZBANK_PORTAL_REQUIRE_AUTH === '1' || process.env.ZBANK_PORTAL_REQUIRE_AUTH === 'true';
|
||||
}
|
||||
function generateSessionSecret() {
|
||||
return (0, crypto_1.randomBytes)(32).toString('hex');
|
||||
}
|
||||
27
services/settlement-middleware/dist/services/zbank-pos-config.js
vendored
Normal file
27
services/settlement-middleware/dist/services/zbank-pos-config.js
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadZBankPosConfig = loadZBankPosConfig;
|
||||
exports.findCardProduct = findCardProduct;
|
||||
exports.findPosTerminal = findPosTerminal;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
let cached = null;
|
||||
function repoConfigPath(relative) {
|
||||
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
function loadZBankPosConfig() {
|
||||
if (cached)
|
||||
return cached;
|
||||
const configPath = process.env.ZBANK_POS_CONFIG?.trim() || repoConfigPath('config/zbank-pos-cards.v1.json');
|
||||
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
|
||||
return cached;
|
||||
}
|
||||
function findCardProduct(cfg, productId) {
|
||||
return cfg.cardProducts.find((p) => p.id === productId);
|
||||
}
|
||||
function findPosTerminal(cfg, terminalId) {
|
||||
return cfg.posTerminals.find((t) => t.terminalId === terminalId);
|
||||
}
|
||||
81
services/settlement-middleware/dist/store/zbank-card-store.js
vendored
Normal file
81
services/settlement-middleware/dist/store/zbank-card-store.js
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.zbankCardStore = exports.ZBankCardStore = void 0;
|
||||
exports.newCardId = newCardId;
|
||||
exports.newSaleId = newSaleId;
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const crypto_1 = require("crypto");
|
||||
const zbank_pos_config_1 = require("../services/zbank-pos-config");
|
||||
const baseDir = process.env.ZBANK_CARD_STORE_DIR ||
|
||||
path_1.default.resolve(__dirname, '../../../data/zbank-card-store');
|
||||
function storeDir() {
|
||||
const officeId = (0, zbank_pos_config_1.loadZBankPosConfig)().officeId;
|
||||
const dir = path_1.default.join(baseDir, `office-${officeId}`);
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
fs_1.default.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
function cardPath(cardId) {
|
||||
return path_1.default.join(storeDir(), `card-${cardId}.json`);
|
||||
}
|
||||
function salePath(saleId) {
|
||||
return path_1.default.join(storeDir(), `sale-${saleId}.json`);
|
||||
}
|
||||
class ZBankCardStore {
|
||||
saveCard(card) {
|
||||
fs_1.default.writeFileSync(cardPath(card.cardId), JSON.stringify(card, null, 2));
|
||||
}
|
||||
getCard(cardId) {
|
||||
const p = cardPath(cardId);
|
||||
if (!fs_1.default.existsSync(p))
|
||||
return null;
|
||||
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
}
|
||||
listCards(limit = 50) {
|
||||
const dir = storeDir();
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return [];
|
||||
return fs_1.default
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith('card-') && f.endsWith('.json'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')));
|
||||
}
|
||||
saveSale(sale) {
|
||||
fs_1.default.writeFileSync(salePath(sale.saleId), JSON.stringify(sale, null, 2));
|
||||
fs_1.default.writeFileSync(path_1.default.join(storeDir(), `sale-key-${sale.idempotencyKey}.json`), sale.saleId);
|
||||
}
|
||||
getSaleByIdempotency(key) {
|
||||
const ref = path_1.default.join(storeDir(), `sale-key-${key}.json`);
|
||||
if (!fs_1.default.existsSync(ref))
|
||||
return null;
|
||||
const id = fs_1.default.readFileSync(ref, 'utf8').trim();
|
||||
const p = salePath(id);
|
||||
if (!fs_1.default.existsSync(p))
|
||||
return null;
|
||||
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
||||
}
|
||||
listSales(limit = 50) {
|
||||
const dir = storeDir();
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return [];
|
||||
return fs_1.default
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith('sale-') && f.endsWith('.json') && !f.includes('sale-key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')))
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
}
|
||||
}
|
||||
exports.ZBankCardStore = ZBankCardStore;
|
||||
exports.zbankCardStore = new ZBankCardStore();
|
||||
function newCardId() {
|
||||
return `ZCARD-${(0, crypto_1.randomUUID)().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
function newSaleId() {
|
||||
return `ZSALE-${(0, crypto_1.randomUUID)().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const settlement_bridge_1 = require("./settlement-bridge");
|
||||
async function processExternalTransfer(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
@@ -112,6 +113,29 @@ async function processExternalTransfer(input) {
|
||||
});
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
if (req.tradeFinance?.instrument === 'SBLC') {
|
||||
const cover = (0, swift_1.buildMt202Stub)({
|
||||
senderRef: `${req.idempotencyKey}-COV`,
|
||||
relatedRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingInstitution: 'OMNLUS33',
|
||||
beneficiaryInstitution: req.creditorBic ?? 'BANKUS33',
|
||||
remittance: `SBLC cover ${req.tradeFinance.reference ?? req.idempotencyKey}`,
|
||||
});
|
||||
await (0, swift_1.forwardSwiftToListener)(cover);
|
||||
}
|
||||
const advice = (0, swift_1.buildMt910Stub)({
|
||||
transactionRef: `${req.idempotencyKey}-ADV`,
|
||||
relatedRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
accountWithInstitution: req.creditorBic,
|
||||
remittance: `Credit confirmation ${req.beneficiaryName}`,
|
||||
});
|
||||
await (0, swift_1.forwardSwiftToListener)(advice);
|
||||
}
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
|
||||
@@ -130,14 +154,15 @@ async function processExternalTransfer(input) {
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
tokenAddress: (0, settlement_bridge_1.resolveM2TokenAddress)(req.convertToCrypto.tokenLineId),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
}, advance);
|
||||
if (record.phase === 'FAILED')
|
||||
return record;
|
||||
}
|
||||
else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
|
||||
118
services/settlement-middleware/dist/workflows/settlement-bridge.js
vendored
Normal file
118
services/settlement-middleware/dist/workflows/settlement-bridge.js
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveM2TokenAddress = resolveM2TokenAddress;
|
||||
exports.requiresCrossChainBridge = requiresCrossChainBridge;
|
||||
exports.bridgeAlreadyCompleted = bridgeAlreadyCompleted;
|
||||
exports.executeSettlementMintAndBridge = executeSettlementMintAndBridge;
|
||||
exports.primaryChainId = primaryChainId;
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const bridge_1 = require("../adapters/bridge");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const PRIMARY_CHAIN_ID = 138;
|
||||
function loadTokenRegistry() {
|
||||
const registryPath = process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path_1.default.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
|
||||
return JSON.parse(fs_1.default.readFileSync(registryPath, 'utf8'));
|
||||
}
|
||||
function resolveM2TokenAddress(lineId, symbol) {
|
||||
const registry = loadTokenRegistry();
|
||||
const byLine = (0, settlement_core_1.findM2TokenByLineId)(registry, lineId);
|
||||
if (byLine?.address.startsWith('0x') && byLine.address !== '0x0000000000000000000000000000000000000000') {
|
||||
return byLine.address;
|
||||
}
|
||||
if (symbol) {
|
||||
const bySym = registry.tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
|
||||
if (bySym?.address.startsWith('0x') && bySym.address !== '0x0000000000000000000000000000000000000000') {
|
||||
return bySym.address;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function requiresCrossChainBridge(req) {
|
||||
return Boolean(req.convertToCrypto?.enabled &&
|
||||
req.convertToCrypto.targetChainId !== PRIMARY_CHAIN_ID);
|
||||
}
|
||||
function bridgeAlreadyCompleted(record) {
|
||||
return Boolean(record.bridgeTxHash ||
|
||||
record.phase === 'CHAIN_BRIDGE_REQUESTED' ||
|
||||
record.phase === 'CHAIN_BRIDGE_PENDING' ||
|
||||
(record.phase === 'SETTLED' && record.destinationChainId && record.destinationChainId !== PRIMARY_CHAIN_ID));
|
||||
}
|
||||
async function executeSettlementMintAndBridge(record, params, advance) {
|
||||
const req = record.request;
|
||||
const crypto = req.convertToCrypto;
|
||||
if (!crypto?.enabled) {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
const targetChainId = crypto.targetChainId;
|
||||
const mintChainId = PRIMARY_CHAIN_ID;
|
||||
if (targetChainId === mintChainId) {
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: crypto.tokenLineId,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: params.dryRunMint,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: mint.txHash,
|
||||
mintChainId,
|
||||
destinationChainId: targetChainId,
|
||||
});
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
const { bridgeRail } = (0, settlement_core_1.assertBridgeDestinationAllowed)(mintChainId, targetChainId, crypto.tokenLineId, crypto.bridgeRail);
|
||||
if (bridgeAlreadyCompleted(record)) {
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
const tokenAddress = params.tokenAddress ?? resolveM2TokenAddress(crypto.tokenLineId);
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
lineId: crypto.tokenLineId,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: params.dryRunMint,
|
||||
mintToBridgeOperator: true,
|
||||
targetChainId,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: mint.txHash,
|
||||
mintChainId,
|
||||
destinationChainId: targetChainId,
|
||||
});
|
||||
if (bridgeRail === 'trustless-lockbox' && !tokenAddress?.startsWith('0x')) {
|
||||
record.errors.push(`tokenAddress required for trustless bridge (line ${crypto.tokenLineId})`);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
const bridge = await (0, bridge_1.requestChainBridge)({
|
||||
settlementRef: record.settlementId,
|
||||
destinationChainId: targetChainId,
|
||||
tokenAddress,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
bridgeRail,
|
||||
dryRun: params.dryRunBridge,
|
||||
});
|
||||
advance('CHAIN_BRIDGE_REQUESTED', {
|
||||
bridgeTxHash: bridge.bridgeTxHash,
|
||||
bridgeMessageId: bridge.messageId,
|
||||
});
|
||||
if (bridge.status === 'CHAIN_BRIDGE_PENDING' || bridge.status === 'DRY_RUN') {
|
||||
advance('CHAIN_BRIDGE_PENDING');
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
function primaryChainId() {
|
||||
return (0, settlement_core_1.loadOmnlChainRegistry)().primaryChainId || PRIMARY_CHAIN_ID;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ const path_1 = __importDefault(require("path"));
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const settlement_bridge_1 = require("./settlement-bridge");
|
||||
function loadTokenRegistry() {
|
||||
const registryPath = process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path_1.default.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
|
||||
@@ -38,6 +38,7 @@ async function processTokenLoad(input) {
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const targetChainId = req.targetChainId ?? 138;
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
@@ -59,7 +60,7 @@ async function processTokenLoad(input) {
|
||||
rail: 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
targetChainId,
|
||||
tokenLineId: req.lineId,
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
@@ -112,17 +113,15 @@ async function processTokenLoad(input) {
|
||||
}
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
const mint = await (0, omnl_1.requestTokenMint)({
|
||||
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
|
||||
lineId: req.lineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
symbol: req.symbol,
|
||||
tokenAddress: resolveTokenAddress(req),
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
tokenAddress: resolveTokenAddress(req) ?? (0, settlement_bridge_1.resolveM2TokenAddress)(req.lineId, req.symbol),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
}, advance);
|
||||
if (record.phase === 'FAILED')
|
||||
return record;
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
205
services/settlement-middleware/dist/workflows/z-bank-m0-m4.js
vendored
Normal file
205
services/settlement-middleware/dist/workflows/z-bank-m0-m4.js
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processZBankM0ToM4 = processZBankM0ToM4;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const config_1 = require("../config");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const hybx_production_1 = require("../adapters/hybx-production");
|
||||
const omnl_1 = require("../adapters/omnl");
|
||||
const swift_1 = require("../adapters/swift");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const settlement_bridge_1 = require("./settlement-bridge");
|
||||
function toExternalRequest(input, officeId) {
|
||||
return {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: input.valueDate ?? new Date().toISOString().slice(0, 10),
|
||||
currency: input.currency ?? 'USD',
|
||||
amount: input.amount,
|
||||
creditorIban: input.creditorIban,
|
||||
creditorBic: input.creditorBic,
|
||||
beneficiaryName: input.beneficiaryName ?? 'Z Online Bank',
|
||||
orderingName: 'Z Online Bank — OMNL Office 24',
|
||||
remittanceInfo: input.remittanceInfo ?? 'Z Online Bank full settlement M0→M4',
|
||||
moneyLayers: settlement_core_1.ALL_MONEY_LAYERS,
|
||||
rail: 'SWIFT',
|
||||
swiftMessageKind: 'MT103',
|
||||
convertToCrypto: input.recipientAddress
|
||||
? {
|
||||
enabled: true,
|
||||
targetChainId: input.targetChainId ?? 138,
|
||||
tokenLineId: input.tokenLineId ?? 'USD-M2',
|
||||
recipientAddress: input.recipientAddress,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
async function processZBankM0ToM4(input) {
|
||||
const cfg = (0, config_1.loadConfig)();
|
||||
const profile = (0, config_1.loadOfficeProfile)();
|
||||
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
|
||||
const includeM3 = Boolean(input.recipientAddress?.trim());
|
||||
const req = toExternalRequest(input, officeId);
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED')
|
||||
return existing;
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: req,
|
||||
layerSteps: [],
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
const glProfile = {
|
||||
glM0: profile.settlement.glM0 ?? cfg.moneySupply.glM0 ?? '1050',
|
||||
glM1: profile.settlement.glM1 ?? cfg.moneySupply.glM1 ?? '2100',
|
||||
glM2: profile.settlement.glM2 ?? cfg.moneySupply.glM2 ?? '2200',
|
||||
glM3: profile.settlement.glM3 ?? cfg.moneySupply.glM3 ?? '2300',
|
||||
glM4NearMoney: profile.settlement.glM4NearMoney ?? cfg.moneySupply.glM4NearMoney ?? cfg.moneySupply.glSettlement ?? '1000',
|
||||
};
|
||||
try {
|
||||
if (!(0, settlement_core_1.isIbanValid)(req.creditorIban)) {
|
||||
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
|
||||
}
|
||||
advance('VALIDATED');
|
||||
const verbiage = (0, settlement_core_1.rollExternalTransferVerbiage)(req);
|
||||
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
|
||||
const amount = parseFloat(req.amount) || 0;
|
||||
const hops = (0, settlement_core_1.zBankM0ToM4Hops)(includeM3);
|
||||
const steps = [];
|
||||
let lastJournalRef;
|
||||
if (amount > 0) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
for (const hop of hops) {
|
||||
const pair = (0, settlement_core_1.resolveZBankLayerJournal)(hop.from, hop.to, glProfile);
|
||||
if (!pair) {
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: '',
|
||||
creditGl: '',
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hop.to === 'M3') {
|
||||
const { fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(req.amount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (!fullyBacked || !m3Check.fullyBacked) {
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: pair.debitGl,
|
||||
creditGl: pair.creditGl,
|
||||
status: 'failed',
|
||||
});
|
||||
throw new Error(`M2/M3 backing insufficient (${m3Check.loadAmount}/${req.amount} M3 headroom)`);
|
||||
}
|
||||
}
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(pair.debitGl),
|
||||
(0, fineract_1.resolveGlAccountId)(pair.creditGl),
|
||||
]);
|
||||
const ref = `${req.idempotencyKey}-${hop.from}${hop.to}`;
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: req.valueDate,
|
||||
referenceNumber: ref,
|
||||
comments: `${pair.narrative} · ${verbiage.slice(0, 200)}`,
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
lastJournalRef = String(je.resourceId);
|
||||
steps.push({
|
||||
from: hop.from,
|
||||
to: hop.to,
|
||||
debitGl: pair.debitGl,
|
||||
creditGl: pair.creditGl,
|
||||
journalRef: lastJournalRef,
|
||||
status: 'posted',
|
||||
});
|
||||
}
|
||||
}
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: lastJournalRef, layerSteps: steps });
|
||||
if (cfg.rails.hybx.enabled) {
|
||||
const hybx = new hybx_production_1.HybxProductionRail();
|
||||
const pay = await hybx.dispatchPayment({
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
amount: req.amount,
|
||||
currency: req.currency,
|
||||
creditorIban: req.creditorIban,
|
||||
creditorBic: req.creditorBic,
|
||||
beneficiaryName: req.beneficiaryName,
|
||||
remittanceInfo: req.remittanceInfo,
|
||||
rail: req.rail,
|
||||
});
|
||||
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
|
||||
}
|
||||
else {
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
}
|
||||
const swiftRaw = (0, swift_1.buildMt103Stub)({
|
||||
senderRef: req.idempotencyKey,
|
||||
currency: req.currency,
|
||||
amount: req.amount,
|
||||
valueDate: req.valueDate,
|
||||
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
|
||||
beneficiary: req.beneficiaryName,
|
||||
iban: req.creditorIban,
|
||||
bic: req.creditorBic,
|
||||
remittance: req.remittanceInfo,
|
||||
});
|
||||
if (cfg.rails.swift.enabled) {
|
||||
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
|
||||
}
|
||||
const iso = await (0, omnl_1.archiveIso20022)({
|
||||
messageType: 'pacs.008',
|
||||
direction: 'OUTBOUND',
|
||||
raw: swiftRaw,
|
||||
settlementRef: record.settlementId,
|
||||
});
|
||||
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
|
||||
if (req.convertToCrypto?.enabled && req.convertToCrypto.recipientAddress) {
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
|
||||
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (fullyBacked && m3Check.fullyBacked) {
|
||||
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
tokenAddress: (0, settlement_bridge_1.resolveM2TokenAddress)(req.convertToCrypto.tokenLineId),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
}, advance);
|
||||
if (record.phase === 'FAILED')
|
||||
return record;
|
||||
}
|
||||
else {
|
||||
record.errors.push('M3 mint skipped — insufficient M2/M3 backing');
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
}
|
||||
else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
record.errors.push(msg);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
}
|
||||
130
services/settlement-middleware/dist/workflows/zbank-card-load.js
vendored
Normal file
130
services/settlement-middleware/dist/workflows/zbank-card-load.js
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processZBankCardLoad = processZBankCardLoad;
|
||||
const uuid_1 = require("uuid");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const settlement_store_1 = require("../store/settlement-store");
|
||||
const zbank_pos_config_1 = require("../services/zbank-pos-config");
|
||||
const zbank_card_store_1 = require("../store/zbank-card-store");
|
||||
async function processZBankCardLoad(input) {
|
||||
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const officeId = input.officeId ?? cfg.officeId;
|
||||
const product = (0, zbank_pos_config_1.findCardProduct)(cfg, input.cardProductId);
|
||||
if (!product) {
|
||||
throw new Error(`Unknown card product: ${input.cardProductId}`);
|
||||
}
|
||||
const amount = parseFloat(input.amount);
|
||||
const minLoad = parseFloat(product.minLoad);
|
||||
const maxBalance = parseFloat(product.maxBalance);
|
||||
if (!Number.isFinite(amount) || amount < minLoad) {
|
||||
throw new Error(`Minimum load is ${product.minLoad} ${product.currency}`);
|
||||
}
|
||||
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(input.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') {
|
||||
const cardId = existing.request.remittanceInfo?.match(/card (ZCARD-[A-Z0-9]+)/)?.[1];
|
||||
return { settlement: existing, card: cardId ? zbank_card_store_1.zbankCardStore.getCard(cardId) : null };
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
let record = existing ?? {
|
||||
settlementId: (0, uuid_1.v4)(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: input.currency ?? product.currency,
|
||||
amount: input.amount,
|
||||
creditorIban: 'ZBANK-CARD-LOAD',
|
||||
beneficiaryName: input.cardholderName ?? 'Z Card holder',
|
||||
remittanceInfo: `zBank card load · ${product.label}`,
|
||||
moneyLayers: ['M2'],
|
||||
rail: 'INTERNAL',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const advance = (phase, patch = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlement_store_1.settlementStore.save(record);
|
||||
};
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
|
||||
const m2Available = parseFloat(snap.M2.broadMoney) || 0;
|
||||
if (amount > m2Available) {
|
||||
record.errors.push(`Insufficient M2 broad money (${snap.M2.broadMoney} available)`);
|
||||
advance('FAILED');
|
||||
return { settlement: record, card: null };
|
||||
}
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`zBank prepaid card load`,
|
||||
`Office ${officeId} · product ${product.id}`,
|
||||
`Amount ${input.amount} ${product.currency}`,
|
||||
`GL ${cfg.gl.loadSource} → ${cfg.gl.cardPrepaidLiability}`,
|
||||
].join('\n'),
|
||||
});
|
||||
const [m2GlId, cardGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(cfg.gl.loadSource),
|
||||
(0, fineract_1.resolveGlAccountId)(cfg.gl.cardPrepaidLiability),
|
||||
]);
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: input.idempotencyKey,
|
||||
comments: `zBank card load ${product.label}`,
|
||||
debits: [{ glAccountId: m2GlId, amount }],
|
||||
credits: [{ glAccountId: cardGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
const cardLast4 = (input.cardLast4 ?? String(Math.floor(1000 + Math.random() * 9000))).slice(-4);
|
||||
const cardId = (0, zbank_card_store_1.newCardId)();
|
||||
const existingCard = zbank_card_store_1.zbankCardStore.listCards(500).find((c) => c.cardLast4 === cardLast4 && c.productId === product.id);
|
||||
let card = existingCard;
|
||||
if (card) {
|
||||
const newBal = (parseFloat(card.balance) + amount).toFixed(2);
|
||||
if (parseFloat(newBal) > maxBalance) {
|
||||
record.errors.push(`Card max balance ${product.maxBalance} exceeded`);
|
||||
advance('FAILED');
|
||||
return { settlement: record, card };
|
||||
}
|
||||
card = {
|
||||
...card,
|
||||
balance: newBal,
|
||||
updatedAt: now,
|
||||
lastLoadAt: now,
|
||||
};
|
||||
}
|
||||
else {
|
||||
card = {
|
||||
cardId,
|
||||
productId: product.id,
|
||||
productLabel: product.label,
|
||||
officeId,
|
||||
cardholderName: input.cardholderName ?? 'Z Card holder',
|
||||
cardLast4,
|
||||
currency: product.currency,
|
||||
balance: amount.toFixed(2),
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoadAt: now,
|
||||
};
|
||||
}
|
||||
zbank_card_store_1.zbankCardStore.saveCard(card);
|
||||
record.request.remittanceInfo = `zBank card load · card ${card.cardId} ·••• ${cardLast4}`;
|
||||
advance('SETTLED', { chainTxHash: card.cardId });
|
||||
return { settlement: record, card };
|
||||
}
|
||||
catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return { settlement: record, card: null };
|
||||
}
|
||||
}
|
||||
86
services/settlement-middleware/dist/workflows/zbank-pos-sale.js
vendored
Normal file
86
services/settlement-middleware/dist/workflows/zbank-pos-sale.js
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.processZBankPosSale = processZBankPosSale;
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const zbank_pos_config_1 = require("../services/zbank-pos-config");
|
||||
const zbank_card_store_1 = require("../store/zbank-card-store");
|
||||
async function processZBankPosSale(input) {
|
||||
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const officeId = input.officeId ?? cfg.officeId;
|
||||
const terminal = (0, zbank_pos_config_1.findPosTerminal)(cfg, input.terminalId);
|
||||
if (!terminal) {
|
||||
throw new Error(`Unknown POS terminal: ${input.terminalId}`);
|
||||
}
|
||||
const dup = zbank_card_store_1.zbankCardStore.getSaleByIdempotency(input.idempotencyKey);
|
||||
if (dup)
|
||||
return dup;
|
||||
const card = zbank_card_store_1.zbankCardStore.getCard(input.cardId);
|
||||
const now = new Date().toISOString();
|
||||
const amount = parseFloat(input.amount);
|
||||
if (!card || card.status !== 'active') {
|
||||
const declined = {
|
||||
saleId: (0, zbank_card_store_1.newSaleId)(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? 'USD',
|
||||
status: 'declined',
|
||||
declineReason: 'Card not found or blocked',
|
||||
createdAt: now,
|
||||
};
|
||||
zbank_card_store_1.zbankCardStore.saveSale(declined);
|
||||
return declined;
|
||||
}
|
||||
const balance = parseFloat(card.balance);
|
||||
if (!Number.isFinite(amount) || amount <= 0 || amount > balance) {
|
||||
const declined = {
|
||||
saleId: (0, zbank_card_store_1.newSaleId)(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? card.currency,
|
||||
status: 'declined',
|
||||
declineReason: amount > balance ? 'Insufficient card balance' : 'Invalid amount',
|
||||
createdAt: now,
|
||||
};
|
||||
zbank_card_store_1.zbankCardStore.saveSale(declined);
|
||||
return declined;
|
||||
}
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
(0, fineract_1.resolveGlAccountId)(cfg.gl.cardPrepaidLiability),
|
||||
(0, fineract_1.resolveGlAccountId)(cfg.gl.posClearing),
|
||||
]);
|
||||
const je = await (0, fineract_1.postJournalEntry)({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: input.idempotencyKey,
|
||||
comments: `POS ${terminal.terminalId} · card •••${card.cardLast4}`,
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
const newBalance = (balance - amount).toFixed(2);
|
||||
zbank_card_store_1.zbankCardStore.saveCard({
|
||||
...card,
|
||||
balance: newBalance,
|
||||
updatedAt: now,
|
||||
lastSaleAt: now,
|
||||
});
|
||||
const approved = {
|
||||
saleId: (0, zbank_card_store_1.newSaleId)(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? card.currency,
|
||||
fineractJournalRef: String(je.resourceId),
|
||||
status: 'approved',
|
||||
createdAt: now,
|
||||
};
|
||||
zbank_card_store_1.zbankCardStore.saveSale(approved);
|
||||
return approved;
|
||||
}
|
||||
52
services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js
vendored
Normal file
52
services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildZBankProofOfFunds = buildZBankProofOfFunds;
|
||||
const crypto_1 = require("crypto");
|
||||
const settlement_core_1 = require("@dbis/settlement-core");
|
||||
const fineract_1 = require("../adapters/fineract");
|
||||
const zbank_pos_config_1 = require("../services/zbank-pos-config");
|
||||
const config_1 = require("../config");
|
||||
async function buildZBankProofOfFunds(officeId) {
|
||||
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
|
||||
const oid = officeId ?? cfg.officeId;
|
||||
const middleware = (0, config_1.loadConfig)();
|
||||
const balances = await (0, fineract_1.fetchGlBalances)(oid);
|
||||
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(oid, balances);
|
||||
const glCodes = [cfg.proofOfFunds.primaryGlCode, ...cfg.proofOfFunds.secondaryGlCodes];
|
||||
const glBalances = glCodes.map((code) => ({
|
||||
glCode: code,
|
||||
amount: balances[code] ?? '0',
|
||||
}));
|
||||
const primaryAmount = balances[cfg.proofOfFunds.primaryGlCode] ?? snap.M1.gl2100 ?? '0';
|
||||
const generatedAt = new Date().toISOString();
|
||||
const body = {
|
||||
documentType: 'proof-of-funds',
|
||||
generatedAtUtc: generatedAt,
|
||||
settlementRef: `ZBANK-POF-${generatedAt.slice(0, 10).replace(/-/g, '')}`,
|
||||
issuer: {
|
||||
legalName: cfg.issuer.legalName,
|
||||
brand: cfg.issuer.brand,
|
||||
officeId: oid,
|
||||
externalId: cfg.externalId,
|
||||
guosmmNotice: cfg.issuer.guosmmNotice,
|
||||
},
|
||||
attestation: {
|
||||
primaryGlCode: cfg.proofOfFunds.primaryGlCode,
|
||||
primaryAmountUsd: primaryAmount,
|
||||
currency: 'USD',
|
||||
methodology: cfg.proofOfFunds.methodology,
|
||||
},
|
||||
moneySupply: snap,
|
||||
glBalances,
|
||||
cardProgram: {
|
||||
prepaidGl: cfg.gl.cardPrepaidLiability,
|
||||
prepaidPool: balances[cfg.gl.cardPrepaidLiability] ?? '0',
|
||||
loadSourceGl: cfg.gl.loadSource,
|
||||
posClearingGl: cfg.gl.posClearing,
|
||||
},
|
||||
omnlLegalName: middleware.omnlLegalName,
|
||||
disclaimer: 'Accounting attestation from Fineract GL balances — not a bank guarantee letter. For correspondent banking use official SWIFT MT799/POF where required.',
|
||||
};
|
||||
const sha256 = (0, crypto_1.createHash)('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
return { ...body, integrity: { sha256 } };
|
||||
}
|
||||
53
services/settlement-middleware/src/adapters/bridge.ts
Normal file
53
services/settlement-middleware/src/adapters/bridge.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export async function requestChainBridge(payload: {
|
||||
settlementRef: string;
|
||||
destinationChainId: number;
|
||||
tokenAddress?: string;
|
||||
amount: string;
|
||||
recipient: string;
|
||||
bridgeRail: 'ccip-weth9' | 'trustless-lockbox';
|
||||
dryRun: boolean;
|
||||
decimals?: number;
|
||||
wrapNative?: boolean;
|
||||
}): Promise<{
|
||||
bridgeTxHash?: string;
|
||||
messageId?: string;
|
||||
status: string;
|
||||
}> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${base}/api/v1/omnl/settlement/bridge-to-chain`,
|
||||
{
|
||||
settlementRef: payload.settlementRef,
|
||||
sourceChainId: 138,
|
||||
destinationChainId: payload.destinationChainId,
|
||||
tokenAddress: payload.tokenAddress,
|
||||
amount: payload.amount,
|
||||
recipient: payload.recipient,
|
||||
bridgeRail: payload.bridgeRail,
|
||||
dryRun: payload.dryRun,
|
||||
decimals: payload.decimals,
|
||||
wrapNative: payload.wrapNative,
|
||||
},
|
||||
{ headers, timeout: 180000 },
|
||||
);
|
||||
return {
|
||||
bridgeTxHash: data.bridgeTxHash,
|
||||
messageId: data.messageId,
|
||||
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'CHAIN_BRIDGE_PENDING')),
|
||||
};
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||||
return {
|
||||
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_BRIDGE_ENDPOINT',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export async function requestTokenMint(payload: {
|
||||
dryRun: boolean;
|
||||
symbol?: string;
|
||||
tokenAddress?: string;
|
||||
mintToBridgeOperator?: boolean;
|
||||
targetChainId?: number;
|
||||
}): Promise<{ txHash?: string; status: string }> {
|
||||
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const key = process.env.OMNL_API_KEY || '';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest, ZBankM0M4Request } from '@dbis/settlement-core';
|
||||
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest, ZBankM0M4Request, ZBankCardLoadRequest, ZBankPosSaleRequest } from '@dbis/settlement-core';
|
||||
import { buildMoneySupplySnapshot, loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../../config';
|
||||
import {
|
||||
@@ -13,9 +13,48 @@ import { bindOffice24 } from '../../workflows/office-scope';
|
||||
import { settlementStore } from '../../store/settlement-store';
|
||||
import { processSwiftInbound } from '../../workflows/swift-inbound';
|
||||
import { processZBankM0ToM4 } from '../../workflows/z-bank-m0-m4';
|
||||
import { buildZBankProofOfFunds } from '../../workflows/zbank-proof-of-funds';
|
||||
import { processZBankCardLoad } from '../../workflows/zbank-card-load';
|
||||
import { processZBankPosSale } from '../../workflows/zbank-pos-sale';
|
||||
import { loadZBankPosConfig } from '../../services/zbank-pos-config';
|
||||
import { zbankCardStore } from '../../store/zbank-card-store';
|
||||
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
|
||||
import {
|
||||
buildConsumerAccounts,
|
||||
buildConsumerOverview,
|
||||
buildConsumerTransactions,
|
||||
} from '../../services/zbank-consumer-banking';
|
||||
import { loadZBankConsumerConfig } from '../../services/zbank-consumer-config';
|
||||
import {
|
||||
extractSessionToken,
|
||||
loadZBankPortalAuthConfig,
|
||||
loginWithPasscode,
|
||||
portalAuthRequired,
|
||||
verifySession,
|
||||
} from '../../services/zbank-portal-auth';
|
||||
import { requireApiKey } from '../../middleware/auth';
|
||||
|
||||
function onlineBankEnabled(): boolean {
|
||||
const cfg = loadConfig();
|
||||
return cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
}
|
||||
|
||||
function allowOnlineBankPublic(req: Request, res: Response): boolean {
|
||||
if (onlineBankEnabled()) return true;
|
||||
return requireApiKey(req, res);
|
||||
}
|
||||
|
||||
function allowZBankConsumer(req: Request, res: Response): boolean {
|
||||
if (!onlineBankEnabled()) return requireApiKey(req, res);
|
||||
if (!portalAuthRequired()) return true;
|
||||
const session = verifySession(extractSessionToken(req.headers.authorization));
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Portal login required' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const allow =
|
||||
@@ -236,6 +275,7 @@ export function createSettlementRouter(): Router {
|
||||
router.post('/convert/fiat-to-crypto', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
// convertToCrypto.targetChainId: 138 (default) or 1 (mint on 138 + CCIP/trustless bridge to Ethereum)
|
||||
const body = req.body as ExternalTransferRequest;
|
||||
const merged = bindOffice24({
|
||||
...body,
|
||||
@@ -352,5 +392,203 @@ export function createSettlementRouter(): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/auth/bootstrap', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res)) return;
|
||||
try {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
res.json({
|
||||
requireAuth: portalAuthRequired(),
|
||||
users: cfg.users.map((u) => ({ phone: u.phone, displayName: u.displayName })),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/z-bank/auth/login', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res)) return;
|
||||
const phone = String(req.body?.phone ?? '').trim();
|
||||
const passcode = String(req.body?.passcode ?? '');
|
||||
if (!phone || !passcode) {
|
||||
res.status(400).json({ error: 'Phone and passcode are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { token, session } = loginWithPasscode(phone, passcode);
|
||||
res.json({
|
||||
token,
|
||||
session: {
|
||||
userId: session.userId,
|
||||
displayName: session.displayName,
|
||||
phone: session.phone,
|
||||
email: session.email,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(401).json({ error: e instanceof Error ? e.message : 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/auth/session', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res)) return;
|
||||
const session = verifySession(extractSessionToken(req.headers.authorization));
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Invalid or expired session' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
session: {
|
||||
userId: session.userId,
|
||||
displayName: session.displayName,
|
||||
phone: session.phone,
|
||||
email: session.email,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/z-bank/auth/logout', (req, res) => {
|
||||
if (!allowOnlineBankPublic(req, res)) return;
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/overview', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
try {
|
||||
res.json(await buildConsumerOverview());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/profile', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
try {
|
||||
const overview = await buildConsumerOverview();
|
||||
res.json({ profile: overview.profile, limits: overview.limits });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/accounts', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
try {
|
||||
const posCfg = loadZBankPosConfig();
|
||||
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(
|
||||
posCfg.officeId,
|
||||
);
|
||||
res.json({ accounts, totalBalance, totalBalanceCurrency: 'USD', fineractConnected, fineractLive });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/cards', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
buildConsumerOverview()
|
||||
.then((o) => res.json({ cards: o.cards }))
|
||||
.catch((e) => res.status(500).json({ error: e instanceof Error ? e.message : String(e) }));
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/transactions', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
const limit = Number(req.query.limit ?? 50);
|
||||
res.json({ transactions: buildConsumerTransactions(limit) });
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/limits', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
res.json(loadZBankConsumerConfig().limits);
|
||||
});
|
||||
|
||||
router.get('/z-bank/consumer/accounts/:accountId', async (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
try {
|
||||
const posCfg = loadZBankPosConfig();
|
||||
const { accounts } = await buildConsumerAccounts(posCfg.officeId);
|
||||
const account = accounts.find((a) => a.id === req.params.accountId);
|
||||
if (!account) {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
return;
|
||||
}
|
||||
res.json(account);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/proof-of-funds', async (req, res) => {
|
||||
const cfg = loadConfig();
|
||||
const allowPublic =
|
||||
cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
|
||||
if (!allowPublic && !requireApiKey(req, res)) return;
|
||||
try {
|
||||
const officeRaw = String(req.query.officeId ?? '').trim();
|
||||
const officeId = officeRaw ? parseInt(officeRaw, 10) : loadZBankPosConfig().officeId;
|
||||
res.json(await buildZBankProofOfFunds(officeId));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/pos/config', (_req, res) => {
|
||||
try {
|
||||
const cfg = loadZBankPosConfig();
|
||||
res.json({
|
||||
officeId: cfg.officeId,
|
||||
issuer: cfg.issuer,
|
||||
cardProducts: cfg.cardProducts,
|
||||
posTerminals: cfg.posTerminals,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/z-bank/pos/cards', (req, res) => {
|
||||
if (!allowZBankConsumer(req, res)) return;
|
||||
res.json({ cards: zbankCardStore.listCards(Number(req.query.limit ?? 50)) });
|
||||
});
|
||||
|
||||
router.get('/z-bank/pos/cards/:cardId', (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
const card = zbankCardStore.getCard(req.params.cardId);
|
||||
if (!card) {
|
||||
res.status(404).json({ error: 'Card not found' });
|
||||
return;
|
||||
}
|
||||
res.json(card);
|
||||
});
|
||||
|
||||
router.post('/z-bank/pos/card-load', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as ZBankCardLoadRequest;
|
||||
if (!body.idempotencyKey || !body.cardProductId || !body.amount) {
|
||||
res.status(400).json({ error: 'idempotencyKey, cardProductId, amount required' });
|
||||
return;
|
||||
}
|
||||
const out = await processZBankCardLoad(body);
|
||||
res.status(out.settlement.phase === 'FAILED' ? 422 : 200).json(out);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/z-bank/pos/sale', async (req, res) => {
|
||||
if (!requireApiKey(req, res)) return;
|
||||
try {
|
||||
const body = req.body as ZBankPosSaleRequest;
|
||||
if (!body.idempotencyKey || !body.cardId || !body.terminalId || !body.amount) {
|
||||
res.status(400).json({ error: 'idempotencyKey, cardId, terminalId, amount required' });
|
||||
return;
|
||||
}
|
||||
const sale = await processZBankPosSale(body);
|
||||
res.status(sale?.status === 'declined' ? 422 : 200).json(sale);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export type MiddlewareConfig = {
|
||||
requireApiKey: boolean;
|
||||
allowHybxProduction: boolean;
|
||||
allowChainMintExecute: boolean;
|
||||
allowChainBridgeExecute: boolean;
|
||||
onlineBankMode?: boolean;
|
||||
};
|
||||
};
|
||||
@@ -80,6 +81,15 @@ export function loadConfig(): MiddlewareConfig {
|
||||
process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
|
||||
repoConfigPath('config/settlement-middleware.v1.json');
|
||||
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as MiddlewareConfig;
|
||||
if (cached.production.allowChainBridgeExecute === undefined) {
|
||||
cached.production.allowChainBridgeExecute = false;
|
||||
}
|
||||
if (process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1') {
|
||||
cached.production.allowChainBridgeExecute = true;
|
||||
}
|
||||
if (process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1') {
|
||||
cached.production.allowChainMintExecute = true;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,20 @@ import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { startServer } from './server';
|
||||
|
||||
const rootEnv = path.resolve(__dirname, '../../../.env');
|
||||
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
|
||||
const envCandidates = [
|
||||
path.resolve(__dirname, '../../../.env'),
|
||||
path.resolve(__dirname, '../../.env'),
|
||||
path.resolve(__dirname, '../.env'),
|
||||
];
|
||||
for (const envPath of envCandidates) {
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
}
|
||||
const serviceEnv = path.resolve(__dirname, '../.env');
|
||||
if (existsSync(serviceEnv)) {
|
||||
dotenv.config({ path: serviceEnv, override: true });
|
||||
}
|
||||
dotenv.config();
|
||||
|
||||
startServer();
|
||||
startServer();
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { SettlementRecord, ZBankPosSaleRecord, ZBankPrepaidCard } from '@dbis/settlement-core';
|
||||
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
|
||||
import { fetchGlBalances, fineractOfficeReachable } from '../adapters/fineract';
|
||||
import { loadOfficeProfile } from '../config';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { zbankCardStore } from '../store/zbank-card-store';
|
||||
import { loadZBankPosConfig } from './zbank-pos-config';
|
||||
import { loadZBankConsumerConfig, type ZBankConsumerConfig } from './zbank-consumer-config';
|
||||
|
||||
export type ConsumerAccount = {
|
||||
id: string;
|
||||
currency: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
balance: string;
|
||||
availableBalance: string;
|
||||
type: 'current' | 'savings' | 'reserve' | 'crypto' | 'fiat_rail';
|
||||
moneyLayer: string;
|
||||
details: {
|
||||
iban: string | null;
|
||||
bic: string;
|
||||
accountNumber: string;
|
||||
sortCode?: string | null;
|
||||
routingNumber?: string;
|
||||
bankName: string;
|
||||
bankAddress: string;
|
||||
shareText: string;
|
||||
accountName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConsumerCard = {
|
||||
cardId: string;
|
||||
tier: string;
|
||||
label: string;
|
||||
gradient: string;
|
||||
network: string;
|
||||
last4: string;
|
||||
cardholderName: string;
|
||||
currency: string;
|
||||
balance: string;
|
||||
status: 'active' | 'frozen' | 'virtual';
|
||||
productId: string;
|
||||
expiry?: string;
|
||||
};
|
||||
|
||||
export type ConsumerTransaction = {
|
||||
id: string;
|
||||
type: 'transfer' | 'exchange' | 'card_payment' | 'card_load' | 'deposit' | 'settlement' | 'fee';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
direction: 'in' | 'out';
|
||||
status: 'completed' | 'pending' | 'failed';
|
||||
createdAt: string;
|
||||
reference?: string;
|
||||
};
|
||||
|
||||
export type ConsumerOverview = {
|
||||
asOf: string;
|
||||
profile: {
|
||||
displayName: string;
|
||||
plan: string;
|
||||
planTier: string;
|
||||
planPrice: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: ZBankConsumerConfig['profile']['address'];
|
||||
memberSince: string;
|
||||
kycStatus: string;
|
||||
kycLevel: string;
|
||||
officeId: number;
|
||||
externalId: string;
|
||||
};
|
||||
totalBalance: string;
|
||||
totalBalanceCurrency: string;
|
||||
accounts: ConsumerAccount[];
|
||||
cards: ConsumerCard[];
|
||||
transactions: ConsumerTransaction[];
|
||||
limits: ZBankConsumerConfig['limits'];
|
||||
fineractConnected: boolean;
|
||||
fineractLive: boolean;
|
||||
};
|
||||
|
||||
const ACCOUNT_FLAGS: Record<string, string> = {
|
||||
USD: '🇺🇸',
|
||||
EUR: '🇪🇺',
|
||||
GBP: '🇬🇧',
|
||||
SAVINGS: '💰',
|
||||
RESERVE: '🏦',
|
||||
CRYPTO: '⛓',
|
||||
};
|
||||
|
||||
function parseTs(iso?: string): number {
|
||||
return iso ? Date.parse(iso) || 0 : 0;
|
||||
}
|
||||
|
||||
function settlementToTransaction(r: SettlementRecord): ConsumerTransaction | null {
|
||||
const req = r.request;
|
||||
const amount = req.amount ?? '0';
|
||||
const currency = req.currency ?? 'USD';
|
||||
const failed = r.phase === 'FAILED';
|
||||
const pending = !failed && r.phase !== 'SETTLED';
|
||||
const remittance = req.remittanceInfo ?? req.beneficiaryName ?? r.settlementId;
|
||||
const outbound = Boolean(req.creditorIban || req.beneficiaryName);
|
||||
|
||||
let type: ConsumerTransaction['type'] = 'settlement';
|
||||
if (remittance.toLowerCase().includes('card load')) type = 'card_load';
|
||||
else if (req.rail?.includes('SWIFT') || req.creditorIban) type = 'transfer';
|
||||
else if (remittance.toLowerCase().includes('swap') || remittance.toLowerCase().includes('exchange')) {
|
||||
type = 'exchange';
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.settlementId,
|
||||
type,
|
||||
title: remittance.slice(0, 64) || 'Settlement',
|
||||
subtitle: `${req.rail ?? 'OMNL'} · ${r.phase}`,
|
||||
amount,
|
||||
currency,
|
||||
direction: type === 'card_load' ? 'in' : outbound ? 'out' : 'in',
|
||||
status: failed ? 'failed' : pending ? 'pending' : 'completed',
|
||||
createdAt: r.updatedAt ?? r.createdAt,
|
||||
reference: r.fineractJournalRef ?? r.hybxPaymentId,
|
||||
};
|
||||
}
|
||||
|
||||
function saleToTransaction(s: ZBankPosSaleRecord): ConsumerTransaction {
|
||||
return {
|
||||
id: s.saleId,
|
||||
type: 'card_payment',
|
||||
title: s.merchantName,
|
||||
subtitle: `Card •••• ${s.cardId.slice(-4)} · ${s.terminalId}`,
|
||||
amount: s.amount,
|
||||
currency: s.currency,
|
||||
direction: 'out',
|
||||
status: s.status === 'approved' ? 'completed' : 'failed',
|
||||
createdAt: s.createdAt,
|
||||
reference: s.fineractJournalRef,
|
||||
};
|
||||
}
|
||||
|
||||
function cardToDisplay(card: ZBankPrepaidCard, cfg: ReturnType<typeof loadZBankConsumerConfig>): ConsumerCard {
|
||||
const tierMeta = cfg.cardTierMap[card.productId] ?? {
|
||||
tier: 'standard',
|
||||
label: card.productLabel,
|
||||
gradient: 'standard',
|
||||
network: 'Visa',
|
||||
};
|
||||
return {
|
||||
cardId: card.cardId,
|
||||
tier: tierMeta.tier,
|
||||
label: tierMeta.label,
|
||||
gradient: tierMeta.gradient,
|
||||
network: tierMeta.network,
|
||||
last4: card.cardLast4,
|
||||
cardholderName: card.cardholderName,
|
||||
currency: card.currency,
|
||||
balance: card.balance,
|
||||
status: card.status === 'blocked' ? 'frozen' : 'active',
|
||||
productId: card.productId,
|
||||
expiry: '12/28',
|
||||
};
|
||||
}
|
||||
|
||||
function virtualCards(cfg: ReturnType<typeof loadZBankConsumerConfig>, holder: string): ConsumerCard[] {
|
||||
return cfg.virtualCardProducts.map((p, i) => ({
|
||||
cardId: `virtual-${p.productId}`,
|
||||
tier: p.tier,
|
||||
label: p.label,
|
||||
gradient: p.gradient,
|
||||
network: p.network,
|
||||
last4: String(9000 + i * 111).slice(-4),
|
||||
cardholderName: holder,
|
||||
currency: 'USD',
|
||||
balance: '0.00',
|
||||
status: 'virtual' as const,
|
||||
productId: p.productId,
|
||||
expiry: '12/28',
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildConsumerTransactions(limit = 50): ConsumerTransaction[] {
|
||||
const settlements = settlementStore
|
||||
.list(200)
|
||||
.map(settlementToTransaction)
|
||||
.filter(Boolean) as ConsumerTransaction[];
|
||||
|
||||
const sales = zbankCardStore.listSales(100).map(saleToTransaction);
|
||||
|
||||
return [...settlements, ...sales]
|
||||
.sort((a, b) => parseTs(b.createdAt) - parseTs(a.createdAt))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function buildConsumerAccounts(
|
||||
officeId: number,
|
||||
): Promise<{ accounts: ConsumerAccount[]; totalBalance: string; fineractConnected: boolean; fineractLive: boolean }> {
|
||||
const cfg = loadZBankConsumerConfig();
|
||||
const posCfg = loadZBankPosConfig();
|
||||
const balances = await fetchGlBalances(officeId);
|
||||
const snap = buildMoneySupplySnapshot(officeId, balances);
|
||||
const fineractConnected = await fineractOfficeReachable(officeId);
|
||||
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
|
||||
const fineractLive = fineractConnected && hasActivity;
|
||||
|
||||
const m1 = snap.M1.circulating ?? snap.M1.gl2100 ?? '0';
|
||||
const m2 = snap.M2.broadMoney ?? snap.M2.gl2200 ?? '0';
|
||||
const m0 = snap.M0.gl1050 ?? '0';
|
||||
const m3 = snap.M3.tokenLiabilities ?? snap.M3.gl2300 ?? '0';
|
||||
const m4 = snap.M4.nearMoney ?? snap.M4.gl1000 ?? '0';
|
||||
const cardPool = balances[posCfg.gl.cardPrepaidLiability] ?? '0';
|
||||
|
||||
const usdDetails = cfg.accountDetails.USD;
|
||||
const eurDetails = cfg.accountDetails.EUR;
|
||||
const gbpDetails = cfg.accountDetails.GBP;
|
||||
|
||||
const accounts: ConsumerAccount[] = [
|
||||
{
|
||||
id: 'usd-main',
|
||||
currency: 'USD',
|
||||
name: 'Main · USD',
|
||||
flag: ACCOUNT_FLAGS.USD,
|
||||
balance: m1,
|
||||
availableBalance: m1,
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: usdDetails,
|
||||
},
|
||||
{
|
||||
id: 'usd-savings',
|
||||
currency: 'USD',
|
||||
name: 'Savings',
|
||||
flag: ACCOUNT_FLAGS.SAVINGS,
|
||||
balance: m2,
|
||||
availableBalance: m2,
|
||||
type: 'savings',
|
||||
moneyLayer: 'M2',
|
||||
details: { ...usdDetails, accountName: 'USD Savings (M2 tokenized)' },
|
||||
},
|
||||
{
|
||||
id: 'eur',
|
||||
currency: 'EUR',
|
||||
name: 'Euro',
|
||||
flag: ACCOUNT_FLAGS.EUR,
|
||||
balance: '0.00',
|
||||
availableBalance: '0.00',
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: eurDetails,
|
||||
},
|
||||
{
|
||||
id: 'gbp',
|
||||
currency: 'GBP',
|
||||
name: 'British Pound',
|
||||
flag: ACCOUNT_FLAGS.GBP,
|
||||
balance: '0.00',
|
||||
availableBalance: '0.00',
|
||||
type: 'current',
|
||||
moneyLayer: 'M1',
|
||||
details: gbpDetails,
|
||||
},
|
||||
{
|
||||
id: 'usd-cards',
|
||||
currency: 'USD',
|
||||
name: 'Card balance',
|
||||
flag: '💳',
|
||||
balance: cardPool,
|
||||
availableBalance: cardPool,
|
||||
type: 'current',
|
||||
moneyLayer: 'M2',
|
||||
details: { ...usdDetails, accountName: 'Prepaid card pool (GL 2210)' },
|
||||
},
|
||||
{
|
||||
id: 'reserve',
|
||||
currency: 'USD',
|
||||
name: 'Reserve',
|
||||
flag: ACCOUNT_FLAGS.RESERVE,
|
||||
balance: m0,
|
||||
availableBalance: m0,
|
||||
type: 'reserve',
|
||||
moneyLayer: 'M0',
|
||||
details: { ...usdDetails, accountName: 'M0 base money reserve' },
|
||||
},
|
||||
{
|
||||
id: 'crypto',
|
||||
currency: 'USD',
|
||||
name: 'On-chain',
|
||||
flag: ACCOUNT_FLAGS.CRYPTO,
|
||||
balance: m3,
|
||||
availableBalance: m3,
|
||||
type: 'crypto',
|
||||
moneyLayer: 'M3',
|
||||
details: { ...usdDetails, accountName: 'M3 digital / on-chain' },
|
||||
},
|
||||
{
|
||||
id: 'swift',
|
||||
currency: 'USD',
|
||||
name: 'SWIFT rails',
|
||||
flag: '🌐',
|
||||
balance: m4,
|
||||
availableBalance: m4,
|
||||
type: 'fiat_rail',
|
||||
moneyLayer: 'M4',
|
||||
details: { ...usdDetails, accountName: 'M4 SWIFT settlement' },
|
||||
},
|
||||
];
|
||||
|
||||
const total = (
|
||||
parseFloat(m1 || '0') +
|
||||
parseFloat(m2 || '0') +
|
||||
parseFloat(cardPool || '0')
|
||||
).toFixed(2);
|
||||
|
||||
return { accounts, totalBalance: total, fineractConnected, fineractLive };
|
||||
}
|
||||
|
||||
export async function buildConsumerOverview(): Promise<ConsumerOverview> {
|
||||
const cfg = loadZBankConsumerConfig();
|
||||
const posCfg = loadZBankPosConfig();
|
||||
const office = loadOfficeProfile();
|
||||
const holder = office.name?.split('—')[0]?.trim() ?? 'Z Bank Customer';
|
||||
|
||||
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(posCfg.officeId);
|
||||
|
||||
const storedCards = zbankCardStore.listCards(20);
|
||||
const cards =
|
||||
storedCards.length > 0
|
||||
? storedCards.map((c) => cardToDisplay(c, cfg))
|
||||
: virtualCards(cfg, holder);
|
||||
|
||||
const transactions = buildConsumerTransactions(30);
|
||||
|
||||
return {
|
||||
asOf: new Date().toISOString(),
|
||||
profile: {
|
||||
displayName: holder,
|
||||
plan: cfg.profile.plan,
|
||||
planTier: cfg.profile.planTier,
|
||||
planPrice: cfg.profile.planPrice,
|
||||
email: cfg.profile.email,
|
||||
phone: cfg.profile.phone,
|
||||
address: cfg.profile.address,
|
||||
memberSince: cfg.profile.memberSince,
|
||||
kycStatus: cfg.profile.kycStatus,
|
||||
kycLevel: cfg.profile.kycLevel,
|
||||
officeId: posCfg.officeId,
|
||||
externalId: posCfg.externalId,
|
||||
},
|
||||
totalBalance,
|
||||
totalBalanceCurrency: 'USD',
|
||||
accounts,
|
||||
cards,
|
||||
transactions,
|
||||
limits: cfg.limits,
|
||||
fineractConnected,
|
||||
fineractLive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type ZBankConsumerConfig = {
|
||||
version: string;
|
||||
profile: {
|
||||
plan: string;
|
||||
planTier: string;
|
||||
planPrice: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: {
|
||||
line1: string;
|
||||
city: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
countryName: string;
|
||||
};
|
||||
memberSince: string;
|
||||
kycStatus: string;
|
||||
kycLevel: string;
|
||||
};
|
||||
accountDetails: Record<
|
||||
string,
|
||||
{
|
||||
accountName: string;
|
||||
iban: string | null;
|
||||
bic: string;
|
||||
accountNumber: string;
|
||||
sortCode?: string | null;
|
||||
routingNumber?: string;
|
||||
bankName: string;
|
||||
bankAddress: string;
|
||||
shareText: string;
|
||||
}
|
||||
>;
|
||||
limits: {
|
||||
currency: string;
|
||||
dailyTransferLimit: string;
|
||||
dailyTransferUsed: string;
|
||||
monthlyTransferLimit: string;
|
||||
monthlyTransferUsed: string;
|
||||
cardSpendDailyLimit: string;
|
||||
cardSpendDailyUsed: string;
|
||||
atmDailyLimit: string;
|
||||
atmDailyUsed: string;
|
||||
topUpMonthlyLimit: string;
|
||||
};
|
||||
cardTierMap: Record<string, { tier: string; label: string; gradient: string; network: string }>;
|
||||
virtualCardProducts: Array<{
|
||||
tier: string;
|
||||
label: string;
|
||||
gradient: string;
|
||||
productId: string;
|
||||
network: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
let cached: ZBankConsumerConfig | null = null;
|
||||
|
||||
function repoConfigPath(relative: string): string {
|
||||
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
export function loadZBankConsumerConfig(): ZBankConsumerConfig {
|
||||
if (cached) return cached;
|
||||
const configPath =
|
||||
process.env.ZBANK_CONSUMER_CONFIG?.trim() ||
|
||||
repoConfigPath('config/zbank-consumer-account.v1.json');
|
||||
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankConsumerConfig;
|
||||
return cached;
|
||||
}
|
||||
169
services/settlement-middleware/src/services/zbank-portal-auth.ts
Normal file
169
services/settlement-middleware/src/services/zbank-portal-auth.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type ZBankPortalUser = {
|
||||
id: string;
|
||||
phone: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type ZBankPortalAuthConfig = {
|
||||
version: string;
|
||||
sessionTtlHours: number;
|
||||
maxAttempts: number;
|
||||
lockoutMinutes: number;
|
||||
users: ZBankPortalUser[];
|
||||
};
|
||||
|
||||
export type PortalSession = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const attemptLog = new Map<string, { count: number; lockedUntil?: number }>();
|
||||
let cachedConfig: ZBankPortalAuthConfig | null = null;
|
||||
|
||||
function repoConfigPath(relative: string): string {
|
||||
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
export function loadZBankPortalAuthConfig(): ZBankPortalAuthConfig {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
const configPath =
|
||||
process.env.ZBANK_PORTAL_AUTH_CONFIG?.trim() ||
|
||||
repoConfigPath('config/zbank-portal-auth.v1.json');
|
||||
cachedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankPortalAuthConfig;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
function sessionSecret(): string {
|
||||
const secret = process.env.ZBANK_PORTAL_SESSION_SECRET?.trim();
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error('ZBANK_PORTAL_SESSION_SECRET must be set (min 16 chars) for portal login');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function configuredPasscode(): string {
|
||||
const code = process.env.ZBANK_PORTAL_PASSCODE?.trim();
|
||||
if (!code) throw new Error('ZBANK_PORTAL_PASSCODE is not configured');
|
||||
return code;
|
||||
}
|
||||
|
||||
function normalizePhone(input: string): string {
|
||||
return input.replace(/[\s-]/g, '');
|
||||
}
|
||||
|
||||
function signPayload(payload: string): string {
|
||||
return createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
export function issueSession(user: ZBankPortalUser): string {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body: PortalSession = {
|
||||
userId: user.id,
|
||||
displayName: user.displayName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
iat: now,
|
||||
exp: now + cfg.sessionTtlHours * 3600,
|
||||
};
|
||||
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
|
||||
return `${payload}.${signPayload(payload)}`;
|
||||
}
|
||||
|
||||
export function verifySession(token: string | undefined): PortalSession | null {
|
||||
if (!token?.includes('.')) return null;
|
||||
const [payload, sig] = token.split('.', 2);
|
||||
if (!payload || !sig) return null;
|
||||
const expected = signPayload(payload);
|
||||
try {
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as PortalSession;
|
||||
if (!session.exp || session.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractSessionToken(authHeader: string | undefined): string | undefined {
|
||||
if (!authHeader) return undefined;
|
||||
const m = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return m?.[1]?.trim();
|
||||
}
|
||||
|
||||
function secureCompare(a: string, b: string): boolean {
|
||||
const ha = createHash('sha256').update(a).digest();
|
||||
const hb = createHash('sha256').update(b).digest();
|
||||
return timingSafeEqual(ha, hb);
|
||||
}
|
||||
|
||||
function checkLockout(key: string): string | null {
|
||||
const entry = attemptLog.get(key);
|
||||
if (entry?.lockedUntil && entry.lockedUntil > Date.now()) {
|
||||
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
|
||||
return `Too many attempts. Try again in ${mins} minute(s).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recordFailedAttempt(key: string): void {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const entry = attemptLog.get(key) ?? { count: 0 };
|
||||
entry.count += 1;
|
||||
if (entry.count >= cfg.maxAttempts) {
|
||||
entry.lockedUntil = Date.now() + cfg.lockoutMinutes * 60_000;
|
||||
entry.count = 0;
|
||||
}
|
||||
attemptLog.set(key, entry);
|
||||
}
|
||||
|
||||
export function loginWithPasscode(
|
||||
phone: string,
|
||||
passcode: string,
|
||||
): { token: string; session: PortalSession } {
|
||||
const cfg = loadZBankPortalAuthConfig();
|
||||
const normalized = normalizePhone(phone);
|
||||
const lockKey = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
||||
const locked = checkLockout(lockKey);
|
||||
if (locked) throw new Error(locked);
|
||||
|
||||
const user = cfg.users.find((u) => normalizePhone(u.phone) === normalized);
|
||||
if (!user) {
|
||||
recordFailedAttempt(lockKey);
|
||||
throw new Error('Invalid phone number or passcode');
|
||||
}
|
||||
|
||||
if (!secureCompare(passcode, configuredPasscode())) {
|
||||
recordFailedAttempt(lockKey);
|
||||
throw new Error('Invalid phone number or passcode');
|
||||
}
|
||||
|
||||
attemptLog.delete(lockKey);
|
||||
const token = issueSession(user);
|
||||
const session = verifySession(token);
|
||||
if (!session) throw new Error('Failed to create session');
|
||||
return { token, session };
|
||||
}
|
||||
|
||||
export function portalAuthRequired(): boolean {
|
||||
return process.env.ZBANK_PORTAL_REQUIRE_AUTH === '1' || process.env.ZBANK_PORTAL_REQUIRE_AUTH === 'true';
|
||||
}
|
||||
|
||||
export function generateSessionSecret(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export type ZBankPosConfig = {
|
||||
version: string;
|
||||
officeId: number;
|
||||
externalId: string;
|
||||
issuer: { legalName: string; brand: string; guosmmNotice: string };
|
||||
gl: { loadSource: string; cardPrepaidLiability: string; posClearing: string; pofPrimary: string };
|
||||
proofOfFunds: { primaryGlCode: string; secondaryGlCodes: string[]; methodology: string };
|
||||
cardProducts: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
currency: string;
|
||||
network: string;
|
||||
maxBalance: string;
|
||||
minLoad: string;
|
||||
}>;
|
||||
posTerminals: Array<{ terminalId: string; merchantName: string; mcc: string }>;
|
||||
};
|
||||
|
||||
let cached: ZBankPosConfig | null = null;
|
||||
|
||||
function repoConfigPath(relative: string): string {
|
||||
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
export function loadZBankPosConfig(): ZBankPosConfig {
|
||||
if (cached) return cached;
|
||||
const configPath =
|
||||
process.env.ZBANK_POS_CONFIG?.trim() || repoConfigPath('config/zbank-pos-cards.v1.json');
|
||||
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankPosConfig;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function findCardProduct(cfg: ZBankPosConfig, productId: string) {
|
||||
return cfg.cardProducts.find((p) => p.id === productId);
|
||||
}
|
||||
|
||||
export function findPosTerminal(cfg: ZBankPosConfig, terminalId: string) {
|
||||
return cfg.posTerminals.find((t) => t.terminalId === terminalId);
|
||||
}
|
||||
81
services/settlement-middleware/src/store/zbank-card-store.ts
Normal file
81
services/settlement-middleware/src/store/zbank-card-store.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ZBankPrepaidCard, ZBankPosSaleRecord } from '@dbis/settlement-core';
|
||||
import { loadZBankPosConfig } from '../services/zbank-pos-config';
|
||||
|
||||
const baseDir =
|
||||
process.env.ZBANK_CARD_STORE_DIR ||
|
||||
path.resolve(__dirname, '../../../data/zbank-card-store');
|
||||
|
||||
function storeDir(): string {
|
||||
const officeId = loadZBankPosConfig().officeId;
|
||||
const dir = path.join(baseDir, `office-${officeId}`);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cardPath(cardId: string): string {
|
||||
return path.join(storeDir(), `card-${cardId}.json`);
|
||||
}
|
||||
|
||||
function salePath(saleId: string): string {
|
||||
return path.join(storeDir(), `sale-${saleId}.json`);
|
||||
}
|
||||
|
||||
export class ZBankCardStore {
|
||||
saveCard(card: ZBankPrepaidCard): void {
|
||||
fs.writeFileSync(cardPath(card.cardId), JSON.stringify(card, null, 2));
|
||||
}
|
||||
|
||||
getCard(cardId: string): ZBankPrepaidCard | null {
|
||||
const p = cardPath(cardId);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8')) as ZBankPrepaidCard;
|
||||
}
|
||||
|
||||
listCards(limit = 50): ZBankPrepaidCard[] {
|
||||
const dir = storeDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith('card-') && f.endsWith('.json'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ZBankPrepaidCard);
|
||||
}
|
||||
|
||||
saveSale(sale: ZBankPosSaleRecord): void {
|
||||
fs.writeFileSync(salePath(sale.saleId), JSON.stringify(sale, null, 2));
|
||||
fs.writeFileSync(path.join(storeDir(), `sale-key-${sale.idempotencyKey}.json`), sale.saleId);
|
||||
}
|
||||
|
||||
getSaleByIdempotency(key: string): ZBankPosSaleRecord | null {
|
||||
const ref = path.join(storeDir(), `sale-key-${key}.json`);
|
||||
if (!fs.existsSync(ref)) return null;
|
||||
const id = fs.readFileSync(ref, 'utf8').trim();
|
||||
const p = salePath(id);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8')) as ZBankPosSaleRecord;
|
||||
}
|
||||
|
||||
listSales(limit = 50): ZBankPosSaleRecord[] {
|
||||
const dir = storeDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith('sale-') && f.endsWith('.json') && !f.includes('sale-key-'))
|
||||
.slice(0, limit)
|
||||
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ZBankPosSaleRecord)
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
export const zbankCardStore = new ZBankCardStore();
|
||||
|
||||
export function newCardId(): string {
|
||||
return `ZCARD-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function newSaleId(): string {
|
||||
return `ZSALE-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
|
||||
import { archiveIso20022 } from '../adapters/omnl';
|
||||
import { buildMt102Stub, buildMt103Stub, buildMt202Stub, buildMt910Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
|
||||
|
||||
export async function processExternalTransfer(
|
||||
input: ExternalTransferRequest,
|
||||
@@ -177,14 +178,18 @@ export async function processExternalTransfer(
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
const mint = await requestTokenMint({
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
record = await executeSettlementMintAndBridge(
|
||||
record,
|
||||
{
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
tokenAddress: resolveM2TokenAddress(req.convertToCrypto.tokenLineId),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
},
|
||||
advance,
|
||||
);
|
||||
if (record.phase === 'FAILED') return record;
|
||||
} else {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ExternalTransferRequest, SettlementRecord } from '@dbis/settlement-core';
|
||||
import {
|
||||
assertBridgeDestinationAllowed,
|
||||
findM2TokenByLineId,
|
||||
loadOmnlChainRegistry,
|
||||
type M2TokenRegistryFile,
|
||||
} from '@dbis/settlement-core';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { requestChainBridge } from '../adapters/bridge';
|
||||
import { requestTokenMint } from '../adapters/omnl';
|
||||
|
||||
const PRIMARY_CHAIN_ID = 138;
|
||||
|
||||
function loadTokenRegistry(): M2TokenRegistryFile {
|
||||
const registryPath =
|
||||
process.env.OMNL_M2_TOKEN_REGISTRY ||
|
||||
path.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
|
||||
return JSON.parse(fs.readFileSync(registryPath, 'utf8')) as M2TokenRegistryFile;
|
||||
}
|
||||
|
||||
export function resolveM2TokenAddress(lineId: string, symbol?: string): string | undefined {
|
||||
const registry = loadTokenRegistry();
|
||||
const byLine = findM2TokenByLineId(registry, lineId);
|
||||
if (byLine?.address.startsWith('0x') && byLine.address !== '0x0000000000000000000000000000000000000000') {
|
||||
return byLine.address;
|
||||
}
|
||||
if (symbol) {
|
||||
const bySym = registry.tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
|
||||
if (bySym?.address.startsWith('0x') && bySym.address !== '0x0000000000000000000000000000000000000000') {
|
||||
return bySym.address;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function requiresCrossChainBridge(req: ExternalTransferRequest): boolean {
|
||||
return Boolean(
|
||||
req.convertToCrypto?.enabled &&
|
||||
req.convertToCrypto.targetChainId !== PRIMARY_CHAIN_ID,
|
||||
);
|
||||
}
|
||||
|
||||
export function bridgeAlreadyCompleted(record: SettlementRecord): boolean {
|
||||
return Boolean(
|
||||
record.bridgeTxHash ||
|
||||
record.phase === 'CHAIN_BRIDGE_REQUESTED' ||
|
||||
record.phase === 'CHAIN_BRIDGE_PENDING' ||
|
||||
(record.phase === 'SETTLED' && record.destinationChainId && record.destinationChainId !== PRIMARY_CHAIN_ID),
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeSettlementMintAndBridge(
|
||||
record: SettlementRecord,
|
||||
params: {
|
||||
lineId: string;
|
||||
amount: string;
|
||||
tokenAddress?: string;
|
||||
dryRunMint: boolean;
|
||||
dryRunBridge: boolean;
|
||||
},
|
||||
advance: (phase: SettlementRecord['phase'], patch?: Partial<SettlementRecord>) => void,
|
||||
): Promise<SettlementRecord> {
|
||||
const req = record.request;
|
||||
const crypto = req.convertToCrypto;
|
||||
if (!crypto?.enabled) {
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
|
||||
const targetChainId = crypto.targetChainId;
|
||||
const mintChainId = PRIMARY_CHAIN_ID;
|
||||
|
||||
if (targetChainId === mintChainId) {
|
||||
const mint = await requestTokenMint({
|
||||
lineId: crypto.tokenLineId,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: params.dryRunMint,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: mint.txHash,
|
||||
mintChainId,
|
||||
destinationChainId: targetChainId,
|
||||
});
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
|
||||
const { bridgeRail } = assertBridgeDestinationAllowed(
|
||||
mintChainId,
|
||||
targetChainId,
|
||||
crypto.tokenLineId,
|
||||
crypto.bridgeRail,
|
||||
);
|
||||
|
||||
if (bridgeAlreadyCompleted(record)) {
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
|
||||
const tokenAddress =
|
||||
params.tokenAddress ?? resolveM2TokenAddress(crypto.tokenLineId);
|
||||
|
||||
const mint = await requestTokenMint({
|
||||
lineId: crypto.tokenLineId,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: params.dryRunMint,
|
||||
mintToBridgeOperator: true,
|
||||
targetChainId,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', {
|
||||
chainTxHash: mint.txHash,
|
||||
mintChainId,
|
||||
destinationChainId: targetChainId,
|
||||
});
|
||||
|
||||
if (bridgeRail === 'trustless-lockbox' && !tokenAddress?.startsWith('0x')) {
|
||||
record.errors.push(`tokenAddress required for trustless bridge (line ${crypto.tokenLineId})`);
|
||||
advance('FAILED');
|
||||
return record;
|
||||
}
|
||||
|
||||
const bridge = await requestChainBridge({
|
||||
settlementRef: record.settlementId,
|
||||
destinationChainId: targetChainId,
|
||||
tokenAddress,
|
||||
amount: params.amount,
|
||||
recipient: crypto.recipientAddress,
|
||||
bridgeRail,
|
||||
dryRun: params.dryRunBridge,
|
||||
});
|
||||
|
||||
advance('CHAIN_BRIDGE_REQUESTED', {
|
||||
bridgeTxHash: bridge.bridgeTxHash,
|
||||
bridgeMessageId: bridge.messageId,
|
||||
});
|
||||
|
||||
if (bridge.status === 'CHAIN_BRIDGE_PENDING' || bridge.status === 'DRY_RUN') {
|
||||
advance('CHAIN_BRIDGE_PENDING');
|
||||
}
|
||||
|
||||
advance('SETTLED');
|
||||
return record;
|
||||
}
|
||||
|
||||
export function primaryChainId(): number {
|
||||
return loadOmnlChainRegistry().primaryChainId || PRIMARY_CHAIN_ID;
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@dbis/settlement-core';
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { requestTokenMint } from '../adapters/omnl';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
|
||||
|
||||
function loadTokenRegistry(): M2TokenRegistryFile {
|
||||
const registryPath =
|
||||
@@ -44,6 +44,7 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
|
||||
const profile = loadOfficeProfile();
|
||||
const officeId = settlementOfficeId(input.officeId);
|
||||
const req = { ...input, officeId };
|
||||
const targetChainId = req.targetChainId ?? 138;
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') return existing;
|
||||
@@ -66,7 +67,7 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
|
||||
rail: 'INTERNAL',
|
||||
convertToCrypto: {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
targetChainId,
|
||||
tokenLineId: req.lineId,
|
||||
recipientAddress: req.recipientAddress,
|
||||
},
|
||||
@@ -126,17 +127,18 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
|
||||
const mint = await requestTokenMint({
|
||||
lineId: req.lineId,
|
||||
amount: loadAmount,
|
||||
recipient: req.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
symbol: req.symbol,
|
||||
tokenAddress: resolveTokenAddress(req),
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
advance('SETTLED');
|
||||
record = await executeSettlementMintAndBridge(
|
||||
record,
|
||||
{
|
||||
lineId: req.lineId,
|
||||
amount: loadAmount,
|
||||
tokenAddress: resolveTokenAddress(req) ?? resolveM2TokenAddress(req.lineId, req.symbol),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
},
|
||||
advance,
|
||||
);
|
||||
if (record.phase === 'FAILED') return record;
|
||||
return record;
|
||||
} catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { HybxProductionRail } from '../adapters/hybx-production';
|
||||
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
|
||||
import { archiveIso20022 } from '../adapters/omnl';
|
||||
import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
|
||||
|
||||
function toExternalRequest(input: ZBankM0M4Request, officeId: number): ExternalTransferRequest {
|
||||
return {
|
||||
@@ -38,7 +39,7 @@ function toExternalRequest(input: ZBankM0M4Request, officeId: number): ExternalT
|
||||
convertToCrypto: input.recipientAddress
|
||||
? {
|
||||
enabled: true,
|
||||
targetChainId: 138,
|
||||
targetChainId: input.targetChainId ?? 138,
|
||||
tokenLineId: input.tokenLineId ?? 'USD-M2',
|
||||
recipientAddress: input.recipientAddress,
|
||||
}
|
||||
@@ -203,14 +204,18 @@ export async function processZBankM0ToM4(input: ZBankM0M4Request): Promise<Settl
|
||||
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(req.amount, snap.M2.broadMoney);
|
||||
const m3Check = m3TokenLoadAmount(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
|
||||
if (fullyBacked && m3Check.fullyBacked) {
|
||||
const mint = await requestTokenMint({
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
recipient: req.convertToCrypto.recipientAddress,
|
||||
settlementRef: record.settlementId,
|
||||
dryRun: !cfg.production.allowChainMintExecute,
|
||||
});
|
||||
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
|
||||
record = await executeSettlementMintAndBridge(
|
||||
record,
|
||||
{
|
||||
lineId: req.convertToCrypto.tokenLineId,
|
||||
amount: m3Check.loadAmount,
|
||||
tokenAddress: resolveM2TokenAddress(req.convertToCrypto.tokenLineId),
|
||||
dryRunMint: !cfg.production.allowChainMintExecute,
|
||||
dryRunBridge: !cfg.production.allowChainBridgeExecute,
|
||||
},
|
||||
advance,
|
||||
);
|
||||
if (record.phase === 'FAILED') return record;
|
||||
} else {
|
||||
record.errors.push('M3 mint skipped — insufficient M2/M3 backing');
|
||||
advance('CHAIN_MINT_REQUESTED');
|
||||
|
||||
139
services/settlement-middleware/src/workflows/zbank-card-load.ts
Normal file
139
services/settlement-middleware/src/workflows/zbank-card-load.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { SettlementRecord, ZBankCardLoadRequest } from '@dbis/settlement-core';
|
||||
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
|
||||
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { settlementStore } from '../store/settlement-store';
|
||||
import { findCardProduct, loadZBankPosConfig } from '../services/zbank-pos-config';
|
||||
import { newCardId, zbankCardStore } from '../store/zbank-card-store';
|
||||
|
||||
export async function processZBankCardLoad(input: ZBankCardLoadRequest): Promise<{
|
||||
settlement: SettlementRecord;
|
||||
card: ReturnType<typeof zbankCardStore.getCard>;
|
||||
}> {
|
||||
const cfg = loadZBankPosConfig();
|
||||
const officeId = input.officeId ?? cfg.officeId;
|
||||
const product = findCardProduct(cfg, input.cardProductId);
|
||||
if (!product) {
|
||||
throw new Error(`Unknown card product: ${input.cardProductId}`);
|
||||
}
|
||||
|
||||
const amount = parseFloat(input.amount);
|
||||
const minLoad = parseFloat(product.minLoad);
|
||||
const maxBalance = parseFloat(product.maxBalance);
|
||||
if (!Number.isFinite(amount) || amount < minLoad) {
|
||||
throw new Error(`Minimum load is ${product.minLoad} ${product.currency}`);
|
||||
}
|
||||
|
||||
const existing = settlementStore.getByIdempotencyKey(input.idempotencyKey);
|
||||
if (existing?.phase === 'SETTLED') {
|
||||
const cardId = existing.request.remittanceInfo?.match(/card (ZCARD-[A-Z0-9]+)/)?.[1];
|
||||
return { settlement: existing, card: cardId ? zbankCardStore.getCard(cardId) : null };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let record: SettlementRecord = existing ?? {
|
||||
settlementId: uuidv4(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
phase: 'RECEIVED',
|
||||
request: {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
officeId,
|
||||
valueDate: now.slice(0, 10),
|
||||
currency: input.currency ?? product.currency,
|
||||
amount: input.amount,
|
||||
creditorIban: 'ZBANK-CARD-LOAD',
|
||||
beneficiaryName: input.cardholderName ?? 'Z Card holder',
|
||||
remittanceInfo: `zBank card load · ${product.label}`,
|
||||
moneyLayers: ['M2'],
|
||||
rail: 'INTERNAL',
|
||||
},
|
||||
errors: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
|
||||
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
|
||||
settlementStore.save(record);
|
||||
};
|
||||
|
||||
try {
|
||||
advance('VALIDATED');
|
||||
const balances = await fetchGlBalances(officeId);
|
||||
const snap = buildMoneySupplySnapshot(officeId, balances);
|
||||
const m2Available = parseFloat(snap.M2.broadMoney) || 0;
|
||||
if (amount > m2Available) {
|
||||
record.errors.push(`Insufficient M2 broad money (${snap.M2.broadMoney} available)`);
|
||||
advance('FAILED');
|
||||
return { settlement: record, card: null };
|
||||
}
|
||||
|
||||
advance('VERBIAGE_ROLLED', {
|
||||
verbiageDocument: [
|
||||
`zBank prepaid card load`,
|
||||
`Office ${officeId} · product ${product.id}`,
|
||||
`Amount ${input.amount} ${product.currency}`,
|
||||
`GL ${cfg.gl.loadSource} → ${cfg.gl.cardPrepaidLiability}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const [m2GlId, cardGlId] = await Promise.all([
|
||||
resolveGlAccountId(cfg.gl.loadSource),
|
||||
resolveGlAccountId(cfg.gl.cardPrepaidLiability),
|
||||
]);
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: input.idempotencyKey,
|
||||
comments: `zBank card load ${product.label}`,
|
||||
debits: [{ glAccountId: m2GlId, amount }],
|
||||
credits: [{ glAccountId: cardGlId, amount }],
|
||||
});
|
||||
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
|
||||
advance('HYBX_RAIL_DISPATCHED');
|
||||
advance('ISO20022_ARCHIVED');
|
||||
|
||||
const cardLast4 = (input.cardLast4 ?? String(Math.floor(1000 + Math.random() * 9000))).slice(-4);
|
||||
const cardId = newCardId();
|
||||
const existingCard = zbankCardStore.listCards(500).find((c) => c.cardLast4 === cardLast4 && c.productId === product.id);
|
||||
let card = existingCard;
|
||||
if (card) {
|
||||
const newBal = (parseFloat(card.balance) + amount).toFixed(2);
|
||||
if (parseFloat(newBal) > maxBalance) {
|
||||
record.errors.push(`Card max balance ${product.maxBalance} exceeded`);
|
||||
advance('FAILED');
|
||||
return { settlement: record, card };
|
||||
}
|
||||
card = {
|
||||
...card,
|
||||
balance: newBal,
|
||||
updatedAt: now,
|
||||
lastLoadAt: now,
|
||||
};
|
||||
} else {
|
||||
card = {
|
||||
cardId,
|
||||
productId: product.id,
|
||||
productLabel: product.label,
|
||||
officeId,
|
||||
cardholderName: input.cardholderName ?? 'Z Card holder',
|
||||
cardLast4,
|
||||
currency: product.currency,
|
||||
balance: amount.toFixed(2),
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoadAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
zbankCardStore.saveCard(card);
|
||||
record.request.remittanceInfo = `zBank card load · card ${card.cardId} ·••• ${cardLast4}`;
|
||||
advance('SETTLED', { chainTxHash: card.cardId });
|
||||
return { settlement: record, card };
|
||||
} catch (err) {
|
||||
record.errors.push(err instanceof Error ? err.message : String(err));
|
||||
advance('FAILED');
|
||||
return { settlement: record, card: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ZBankPosSaleRequest } from '@dbis/settlement-core';
|
||||
import { postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
|
||||
import { findPosTerminal, loadZBankPosConfig } from '../services/zbank-pos-config';
|
||||
import { newSaleId, zbankCardStore } from '../store/zbank-card-store';
|
||||
|
||||
export async function processZBankPosSale(input: ZBankPosSaleRequest): Promise<ReturnType<typeof zbankCardStore.getSaleByIdempotency>> {
|
||||
const cfg = loadZBankPosConfig();
|
||||
const officeId = input.officeId ?? cfg.officeId;
|
||||
const terminal = findPosTerminal(cfg, input.terminalId);
|
||||
if (!terminal) {
|
||||
throw new Error(`Unknown POS terminal: ${input.terminalId}`);
|
||||
}
|
||||
|
||||
const dup = zbankCardStore.getSaleByIdempotency(input.idempotencyKey);
|
||||
if (dup) return dup;
|
||||
|
||||
const card = zbankCardStore.getCard(input.cardId);
|
||||
const now = new Date().toISOString();
|
||||
const amount = parseFloat(input.amount);
|
||||
|
||||
if (!card || card.status !== 'active') {
|
||||
const declined = {
|
||||
saleId: newSaleId(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? 'USD',
|
||||
status: 'declined' as const,
|
||||
declineReason: 'Card not found or blocked',
|
||||
createdAt: now,
|
||||
};
|
||||
zbankCardStore.saveSale(declined);
|
||||
return declined;
|
||||
}
|
||||
|
||||
const balance = parseFloat(card.balance);
|
||||
if (!Number.isFinite(amount) || amount <= 0 || amount > balance) {
|
||||
const declined = {
|
||||
saleId: newSaleId(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? card.currency,
|
||||
status: 'declined' as const,
|
||||
declineReason: amount > balance ? 'Insufficient card balance' : 'Invalid amount',
|
||||
createdAt: now,
|
||||
};
|
||||
zbankCardStore.saveSale(declined);
|
||||
return declined;
|
||||
}
|
||||
|
||||
const [debitGlId, creditGlId] = await Promise.all([
|
||||
resolveGlAccountId(cfg.gl.cardPrepaidLiability),
|
||||
resolveGlAccountId(cfg.gl.posClearing),
|
||||
]);
|
||||
const je = await postJournalEntry({
|
||||
officeId,
|
||||
transactionDate: now.slice(0, 10),
|
||||
referenceNumber: input.idempotencyKey,
|
||||
comments: `POS ${terminal.terminalId} · card •••${card.cardLast4}`,
|
||||
debits: [{ glAccountId: debitGlId, amount }],
|
||||
credits: [{ glAccountId: creditGlId, amount }],
|
||||
});
|
||||
|
||||
const newBalance = (balance - amount).toFixed(2);
|
||||
zbankCardStore.saveCard({
|
||||
...card,
|
||||
balance: newBalance,
|
||||
updatedAt: now,
|
||||
lastSaleAt: now,
|
||||
});
|
||||
|
||||
const approved = {
|
||||
saleId: newSaleId(),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
cardId: input.cardId,
|
||||
terminalId: input.terminalId,
|
||||
merchantName: terminal.merchantName,
|
||||
amount: input.amount,
|
||||
currency: input.currency ?? card.currency,
|
||||
fineractJournalRef: String(je.resourceId),
|
||||
status: 'approved' as const,
|
||||
createdAt: now,
|
||||
};
|
||||
zbankCardStore.saveSale(approved);
|
||||
return approved;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
|
||||
import { fetchGlBalances } from '../adapters/fineract';
|
||||
import { loadZBankPosConfig } from '../services/zbank-pos-config';
|
||||
import { loadConfig } from '../config';
|
||||
|
||||
export async function buildZBankProofOfFunds(officeId?: number): Promise<Record<string, unknown>> {
|
||||
const cfg = loadZBankPosConfig();
|
||||
const oid = officeId ?? cfg.officeId;
|
||||
const middleware = loadConfig();
|
||||
const balances = await fetchGlBalances(oid);
|
||||
const snap = buildMoneySupplySnapshot(oid, balances);
|
||||
const glCodes = [cfg.proofOfFunds.primaryGlCode, ...cfg.proofOfFunds.secondaryGlCodes];
|
||||
const glBalances = glCodes.map((code) => ({
|
||||
glCode: code,
|
||||
amount: balances[code] ?? '0',
|
||||
}));
|
||||
const primaryAmount = balances[cfg.proofOfFunds.primaryGlCode] ?? snap.M1.gl2100 ?? '0';
|
||||
const generatedAt = new Date().toISOString();
|
||||
const body = {
|
||||
documentType: 'proof-of-funds',
|
||||
generatedAtUtc: generatedAt,
|
||||
settlementRef: `ZBANK-POF-${generatedAt.slice(0, 10).replace(/-/g, '')}`,
|
||||
issuer: {
|
||||
legalName: cfg.issuer.legalName,
|
||||
brand: cfg.issuer.brand,
|
||||
officeId: oid,
|
||||
externalId: cfg.externalId,
|
||||
guosmmNotice: cfg.issuer.guosmmNotice,
|
||||
},
|
||||
attestation: {
|
||||
primaryGlCode: cfg.proofOfFunds.primaryGlCode,
|
||||
primaryAmountUsd: primaryAmount,
|
||||
currency: 'USD',
|
||||
methodology: cfg.proofOfFunds.methodology,
|
||||
},
|
||||
moneySupply: snap,
|
||||
glBalances,
|
||||
cardProgram: {
|
||||
prepaidGl: cfg.gl.cardPrepaidLiability,
|
||||
prepaidPool: balances[cfg.gl.cardPrepaidLiability] ?? '0',
|
||||
loadSourceGl: cfg.gl.loadSource,
|
||||
posClearingGl: cfg.gl.posClearing,
|
||||
},
|
||||
omnlLegalName: middleware.omnlLegalName,
|
||||
disclaimer:
|
||||
'Accounting attestation from Fineract GL balances — not a bank guarantee letter. For correspondent banking use official SWIFT MT799/POF where required.',
|
||||
};
|
||||
const sha256 = createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
return { ...body, integrity: { sha256 } };
|
||||
}
|
||||
@@ -21,7 +21,18 @@ import {
|
||||
executeErc20Transfer,
|
||||
executeM2TokenMint,
|
||||
chainRailConfigured,
|
||||
getSettlementOperatorAddress,
|
||||
} from '../../services/omnl-settlement-chain';
|
||||
import {
|
||||
bridgeRailConfigured,
|
||||
dryRunCcipWeth9Bridge,
|
||||
executeCcipWeth9Bridge,
|
||||
getBridgeOperatorAddress,
|
||||
} from '../../services/omnl-settlement-bridge';
|
||||
import {
|
||||
dryRunTrustlessLockboxBridge,
|
||||
executeTrustlessLockboxDeposit,
|
||||
} from '../../services/omnl-settlement-trustless-bridge';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -379,7 +390,17 @@ router.get('/omnl/health', async (req: Request, res: Response) => {
|
||||
* POST /omnl/settlement/token-load — M2→M3 fiat-backed token mint (Office 24 settlement).
|
||||
*/
|
||||
router.post('/omnl/settlement/token-load', async (req: Request, res: Response) => {
|
||||
const { lineId, amount, recipient, settlementRef, dryRun, symbol, tokenAddress } = req.body as {
|
||||
const {
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
settlementRef,
|
||||
dryRun,
|
||||
symbol,
|
||||
tokenAddress,
|
||||
mintToBridgeOperator,
|
||||
targetChainId,
|
||||
} = req.body as {
|
||||
lineId?: string;
|
||||
amount?: string;
|
||||
recipient?: string;
|
||||
@@ -387,9 +408,26 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
|
||||
dryRun?: boolean;
|
||||
symbol?: string;
|
||||
tokenAddress?: string;
|
||||
mintToBridgeOperator?: boolean;
|
||||
targetChainId?: number;
|
||||
};
|
||||
if (!lineId || !amount || !recipient) {
|
||||
res.status(400).json({ error: 'lineId, amount, recipient required' });
|
||||
if (!lineId || !amount) {
|
||||
res.status(400).json({ error: 'lineId, amount required' });
|
||||
return;
|
||||
}
|
||||
|
||||
let mintRecipient = recipient;
|
||||
if (mintToBridgeOperator || (typeof targetChainId === 'number' && targetChainId !== 138)) {
|
||||
mintRecipient = getSettlementOperatorAddress() ?? getBridgeOperatorAddress() ?? undefined;
|
||||
if (!mintRecipient) {
|
||||
res.status(400).json({
|
||||
error: 'mintToBridgeOperator requires OMNL_MINT_OPERATOR_PRIVATE_KEY (or OMNL_BRIDGE_OPERATOR_PRIVATE_KEY)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!mintRecipient) {
|
||||
res.status(400).json({ error: 'recipient required when not minting to bridge operator' });
|
||||
return;
|
||||
}
|
||||
const execute =
|
||||
@@ -412,10 +450,13 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
recipient: mintRecipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress: mintTokenAddress ?? null,
|
||||
settlementRef: settlementRef ?? null,
|
||||
mintChainId: 138,
|
||||
targetChainId: targetChainId ?? 138,
|
||||
mintToBridgeOperator: Boolean(mintToBridgeOperator || (targetChainId && targetChainId !== 138)),
|
||||
moneyLayer: 'M3',
|
||||
loadFromGl: '2200',
|
||||
creditGl: '2300',
|
||||
@@ -440,7 +481,7 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
|
||||
try {
|
||||
const { txHash } = await executeM2TokenMint({
|
||||
tokenAddress: mintTokenAddress,
|
||||
recipient,
|
||||
recipient: mintRecipient,
|
||||
amount,
|
||||
});
|
||||
res.json({
|
||||
@@ -448,10 +489,13 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
|
||||
loadId,
|
||||
lineId,
|
||||
amount,
|
||||
recipient,
|
||||
recipient: mintRecipient,
|
||||
symbol: symbol ?? null,
|
||||
tokenAddress: mintTokenAddress,
|
||||
settlementRef: settlementRef ?? null,
|
||||
mintChainId: 138,
|
||||
targetChainId: targetChainId ?? 138,
|
||||
mintToBridgeOperator: Boolean(mintToBridgeOperator || (targetChainId && targetChainId !== 138)),
|
||||
moneyLayer: 'M3',
|
||||
capabilities,
|
||||
txHash,
|
||||
@@ -522,4 +566,149 @@ router.post('/omnl/settlement/token-transfer', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /omnl/settlement/bridge-to-chain — CCIP WETH9 bridge from chain 138 to destination (Phase 1).
|
||||
*/
|
||||
router.post('/omnl/settlement/bridge-to-chain', async (req: Request, res: Response) => {
|
||||
const {
|
||||
settlementRef,
|
||||
sourceChainId,
|
||||
destinationChainId,
|
||||
tokenAddress,
|
||||
amount,
|
||||
recipient,
|
||||
bridgeRail,
|
||||
dryRun,
|
||||
decimals,
|
||||
wrapNative,
|
||||
} = req.body as {
|
||||
settlementRef?: string;
|
||||
sourceChainId?: number;
|
||||
destinationChainId?: number;
|
||||
tokenAddress?: string;
|
||||
amount?: string;
|
||||
recipient?: string;
|
||||
bridgeRail?: 'ccip-weth9' | 'trustless-lockbox';
|
||||
dryRun?: boolean;
|
||||
decimals?: number;
|
||||
wrapNative?: boolean;
|
||||
};
|
||||
|
||||
if (!amount || !recipient) {
|
||||
res.status(400).json({ error: 'amount, recipient required' });
|
||||
return;
|
||||
}
|
||||
if (sourceChainId && sourceChainId !== 138) {
|
||||
res.status(400).json({ error: 'Only chain 138 is supported as bridge source' });
|
||||
return;
|
||||
}
|
||||
const destChainId = destinationChainId ?? 1;
|
||||
if (bridgeRail && bridgeRail !== 'ccip-weth9' && bridgeRail !== 'trustless-lockbox') {
|
||||
res.status(400).json({ error: `Unsupported bridgeRail: ${bridgeRail}` });
|
||||
return;
|
||||
}
|
||||
const rail = bridgeRail ?? 'ccip-weth9';
|
||||
|
||||
const bridgeId = settlementRef ?? `BR-${Date.now()}`;
|
||||
const execute =
|
||||
!dryRun &&
|
||||
(process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1' ||
|
||||
process.env.OMNL_ALLOW_CHAIN_BRIDGE_EXECUTE === '1');
|
||||
|
||||
if (!execute) {
|
||||
if (rail === 'trustless-lockbox') {
|
||||
const preview = await dryRunTrustlessLockboxBridge({
|
||||
recipient,
|
||||
amount,
|
||||
tokenAddress,
|
||||
});
|
||||
res.json({
|
||||
...preview,
|
||||
status: 'DRY_RUN',
|
||||
bridgeId,
|
||||
settlementRef: settlementRef ?? null,
|
||||
sourceChainId: 138,
|
||||
destinationChainId: destChainId,
|
||||
amount,
|
||||
recipient,
|
||||
tokenAddress,
|
||||
message: 'Trustless bridge validated — set SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE=1 to deposit on chain 138',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const preview = await dryRunCcipWeth9Bridge({
|
||||
destinationChainId: destChainId,
|
||||
recipient,
|
||||
amount,
|
||||
tokenAddress,
|
||||
});
|
||||
res.json({
|
||||
...preview,
|
||||
status: 'DRY_RUN',
|
||||
bridgeId,
|
||||
settlementRef: settlementRef ?? null,
|
||||
sourceChainId: 138,
|
||||
tokenAddress: tokenAddress ?? preview.contracts.weth9,
|
||||
message: 'Bridge validated — set SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE=1 to submit on-chain',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (rail === 'trustless-lockbox') {
|
||||
if (!tokenAddress?.startsWith('0x')) {
|
||||
res.status(400).json({ error: 'tokenAddress required for trustless-lockbox bridge' });
|
||||
return;
|
||||
}
|
||||
const { bridgeTxHash, depositId } = await executeTrustlessLockboxDeposit({
|
||||
tokenAddress,
|
||||
amount,
|
||||
recipient,
|
||||
decimals,
|
||||
});
|
||||
res.json({
|
||||
status: 'CHAIN_BRIDGE_PENDING',
|
||||
bridgeId,
|
||||
settlementRef: settlementRef ?? null,
|
||||
bridgeRail: 'trustless-lockbox',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: destChainId,
|
||||
amount,
|
||||
recipient,
|
||||
tokenAddress,
|
||||
bridgeTxHash,
|
||||
messageId: depositId,
|
||||
message:
|
||||
'Trustless lockbox deposit on chain 138 — run settlement-trustless-claim-worker on mainnet to finalize',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { bridgeTxHash, messageId } = await executeCcipWeth9Bridge({
|
||||
destinationChainId: destChainId,
|
||||
recipient,
|
||||
amount,
|
||||
tokenAddress,
|
||||
decimals,
|
||||
wrapNative,
|
||||
});
|
||||
res.json({
|
||||
status: 'CHAIN_BRIDGE_PENDING',
|
||||
bridgeId,
|
||||
settlementRef: settlementRef ?? null,
|
||||
bridgeRail: 'ccip-weth9',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: destChainId,
|
||||
amount,
|
||||
recipient,
|
||||
bridgeTxHash,
|
||||
messageId,
|
||||
message: 'CCIP WETH9 bridge submitted on chain 138 — awaiting mainnet release',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(502).json({ error: msg, status: 'CHAIN_BRIDGE_FAILED', bridgeId });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user