From c80c5364cad57e9ee28e308173b5b9e4c2687307 Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Fri, 3 Jul 2026 02:07:02 -0700 Subject: [PATCH] fix(settlement): add z-bank M0-M4 workflow source for production build Co-authored-by: Cursor --- .../src/workflows/z-bank-m0-m4.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 services/settlement-middleware/src/workflows/z-bank-m0-m4.ts diff --git a/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts b/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts new file mode 100644 index 0000000..38eee5f --- /dev/null +++ b/services/settlement-middleware/src/workflows/z-bank-m0-m4.ts @@ -0,0 +1,230 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ALL_MONEY_LAYERS, + buildMoneySupplySnapshot, + isIbanValid, + m2FiatToTokenLoadAmount, + m3TokenLoadAmount, + rollExternalTransferVerbiage, + resolveZBankLayerJournal, + zBankM0ToM4Hops, + type ExternalTransferRequest, + type SettlementRecord, + type ZBankLayerStep, + type ZBankM0M4Request, +} from '@dbis/settlement-core'; +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 { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift'; +import { settlementStore } from '../store/settlement-store'; + +function toExternalRequest(input: ZBankM0M4Request, officeId: number): ExternalTransferRequest { + 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: ALL_MONEY_LAYERS, + rail: 'SWIFT', + swiftMessageKind: 'MT103', + convertToCrypto: input.recipientAddress + ? { + enabled: true, + targetChainId: 138, + tokenLineId: input.tokenLineId ?? 'USD-M2', + recipientAddress: input.recipientAddress, + } + : undefined, + }; +} + +export async function processZBankM0ToM4(input: ZBankM0M4Request): Promise { + const cfg = loadConfig(); + const profile = loadOfficeProfile(); + const officeId = settlementOfficeId(input.officeId); + const includeM3 = Boolean(input.recipientAddress?.trim()); + const req = toExternalRequest(input, officeId); + + const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey); + if (existing?.phase === 'SETTLED') return existing; + + const now = new Date().toISOString(); + let record: SettlementRecord = existing ?? { + settlementId: uuidv4(), + idempotencyKey: req.idempotencyKey, + phase: 'RECEIVED', + request: req, + layerSteps: [], + errors: [], + createdAt: now, + updatedAt: now, + }; + + const advance = (phase: SettlementRecord['phase'], patch: Partial = {}) => { + record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch }; + 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 (!isIbanValid(req.creditorIban)) { + throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`); + } + advance('VALIDATED'); + + const verbiage = rollExternalTransferVerbiage(req); + advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage }); + + const amount = parseFloat(req.amount) || 0; + const hops = zBankM0ToM4Hops(includeM3); + const steps: ZBankLayerStep[] = []; + let lastJournalRef: string | undefined; + + if (amount > 0) { + const balances = await fetchGlBalances(officeId); + const snap = buildMoneySupplySnapshot(officeId, balances); + + for (const hop of hops) { + const pair = 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 } = m2FiatToTokenLoadAmount(req.amount, snap.M2.broadMoney); + const m3Check = 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([ + resolveGlAccountId(pair.debitGl), + resolveGlAccountId(pair.creditGl), + ]); + const ref = `${req.idempotencyKey}-${hop.from}${hop.to}`; + const je = await 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 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 = 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 forwardSwiftToListener(swiftRaw); + } + const iso = await 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 fetchGlBalances(officeId); + const snap = buildMoneySupplySnapshot(officeId, balances); + 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 }); + } 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; + } +}