diff --git a/services/token-aggregation/public/omnl-compliance-console.html b/services/token-aggregation/public/omnl-compliance-console.html index 0dc4e2d..da790d4 100644 --- a/services/token-aggregation/public/omnl-compliance-console.html +++ b/services/token-aggregation/public/omnl-compliance-console.html @@ -51,6 +51,11 @@ +
+

HO nostro & reserve coverage

+
+
+

Triple-state reconcile

diff --git a/services/token-aggregation/public/omnl-compliance-console.js b/services/token-aggregation/public/omnl-compliance-console.js index e7d2189..237c19f 100644 --- a/services/token-aggregation/public/omnl-compliance-console.js +++ b/services/token-aggregation/public/omnl-compliance-console.js @@ -12,6 +12,7 @@ safe: document.getElementById('safe-kv'), external: document.getElementById('external-kv'), triple: document.getElementById('triple-summary'), + hoLiquidity: document.getElementById('ho-liquidity-kv'), signoffs: document.getElementById('signoffs-summary'), raw: document.getElementById('raw-json'), refreshed: document.getElementById('refreshed-at'), @@ -147,6 +148,24 @@ ]); } + function renderHoLiquidity(snap) { + if (!els.hoLiquidity) return; + if (!snap) { + els.hoLiquidity.textContent = 'HO liquidity snapshot unavailable.'; + return; + } + const cash = snap.cashInBank || {}; + const cov = snap.reserveCoverage || {}; + els.hoLiquidity.innerHTML = kv([ + ['Generated', snap.generatedAt], + ['Nostro cash (GL 13010)', cash.cashInBankUsd != null ? '$' + Number(cash.cashInBankUsd).toLocaleString() : '—'], + ['Reserve coverage', cov.coverageRatio != null ? cov.coverageRatio.toFixed(2) + '×' : '—'], + ['Reserve assets', cov.reserveAssetsUsd != null ? '$' + Number(cov.reserveAssetsUsd).toLocaleString() : '—'], + ['Issued c* USD', cov.issuedCStarUsd != null ? '$' + Number(cov.issuedCStarUsd).toLocaleString() : '—'], + ['Public API', '/token-aggregation/api/v1/omnl/ho-liquidity-snapshot'], + ]); + } + function renderTriple(data) { const t = data.tripleReconcile; if (!t) { @@ -198,6 +217,12 @@ renderSafe(data); renderExternal(data); renderTriple(data); + try { + const hoRes = await fetch(apiUrl('/omnl/ho-liquidity-snapshot'), { headers: apiHeaders() }); + if (hoRes.ok) renderHoLiquidity(await hoRes.json()); + } catch (_) { + renderHoLiquidity(null); + } renderSignoffs(data); els.raw.textContent = JSON.stringify(data, null, 2); els.refreshed.textContent = 'Updated ' + new Date(data.generatedAt).toLocaleString(); diff --git a/services/token-aggregation/src/api/routes/omnl-payout-journal-routes.ts b/services/token-aggregation/src/api/routes/omnl-payout-journal-routes.ts new file mode 100644 index 0000000..23d9f4f --- /dev/null +++ b/services/token-aggregation/src/api/routes/omnl-payout-journal-routes.ts @@ -0,0 +1,101 @@ +import { Router, Request, Response } from 'express'; +import { omnlRateLimiter } from '../middleware/rate-limit'; +import { omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; +import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; +import { + createJournalEntry, + postJournalEntry, + reverseJournalEntry, + getJournalEntry, + createBalanceReservation, + releaseBalanceReservation, + getBalanceReservation, +} from '../../services/omnl-payout-journal'; + +const router = Router(); +router.use(omnlRateLimiter); +router.use(omnlAuditMiddleware); +router.use(omnlRequireApiKeyInProduction); + +function routeParam(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ''; + return value ?? ''; +} + +/** + * POST /journal-entries — payout orchestrator integration (CR-004). + */ +router.post('/journal-entries', (req: Request, res: Response) => { + try { + const record = createJournalEntry(req.body ?? {}); + res.status(201).json(record); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(msg.includes('invalid') ? 422 : 400).json({ error: msg }); + } +}); + +router.get('/journal-entries/:id', (req: Request, res: Response) => { + const record = getJournalEntry(routeParam(req.params.id)); + if (!record) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(record); +}); + +router.post('/journal-entries/:id/post', (req: Request, res: Response) => { + try { + res.json(postJournalEntry(routeParam(req.params.id))); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(msg.includes('not found') ? 404 : 422).json({ error: msg }); + } +}); + +router.post('/journal-entries/:id/reverse', (req: Request, res: Response) => { + try { + res.json(reverseJournalEntry(routeParam(req.params.id))); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(msg.includes('not found') ? 404 : 422).json({ error: msg }); + } +}); + +/** + * POST /balance-reservations — reserve funds before payout dispatch. + */ +router.post('/balance-reservations', (req: Request, res: Response) => { + try { + const body = req.body ?? {}; + if (!body.instructionId || body.amount == null || !body.currency) { + res.status(400).json({ error: 'instructionId, amount, currency required' }); + return; + } + const record = createBalanceReservation(body); + res.status(201).json(record); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(400).json({ error: msg }); + } +}); + +router.get('/balance-reservations/:id', (req: Request, res: Response) => { + const record = getBalanceReservation(routeParam(req.params.id)); + if (!record) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(record); +}); + +router.delete('/balance-reservations/:id', (req: Request, res: Response) => { + try { + res.json(releaseBalanceReservation(routeParam(req.params.id))); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(msg.includes('not found') ? 404 : 422).json({ error: msg }); + } +}); + +export default router; diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 9344ddf..54852ee 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -23,6 +23,7 @@ import partnerPayloadRoutes from './routes/partner-payloads'; import plannerV2Routes from './routes/planner-v2'; import omnlRoutes from './routes/omnl'; import omnlIpsasRoutes from './routes/omnl-ipsas'; +import omnlPayoutJournalRoutes from './routes/omnl-payout-journal-routes'; import omnlComplianceRoutes from './routes/omnl-compliance-routes'; import omnlTerminalRoutes from './routes/omnl-terminal-routes'; import checkpointRoutes from './routes/checkpoint'; @@ -303,6 +304,7 @@ export class ApiServer { this.app.use('/api/v1', omnlTerminalRoutes); this.app.use('/api/v1', omnlRoutes); this.app.use('/api/v1', omnlIpsasRoutes); + this.app.use('/api/v1', omnlPayoutJournalRoutes); this.app.use('/api/v2', plannerV2Routes); // Admin routes (stricter rate limit) diff --git a/services/token-aggregation/src/index.ts b/services/token-aggregation/src/index.ts index ad684f2..7e4876c 100644 --- a/services/token-aggregation/src/index.ts +++ b/services/token-aggregation/src/index.ts @@ -6,6 +6,7 @@ import { closeDatabasePool } from './database/client'; import { logger } from './utils/logger'; import { startRouteMatrixScheduler } from './services/route-matrix-scheduler'; import { startGruReserveCapacityScheduler } from './services/gru-reserve-capacity-scheduler'; +import { startOmnlHoLiquidityScheduler } from './services/omnl-ho-liquidity-scheduler'; import { startIso4217FxScheduler } from './services/iso4217-fx-refresh'; // Load smom-dbis-138 root .env first (single source); works from dist/ or src/ @@ -31,6 +32,7 @@ try { const server = new ApiServer(); startRouteMatrixScheduler(); startGruReserveCapacityScheduler(); +startOmnlHoLiquidityScheduler(); startIso4217FxScheduler(); // Start server diff --git a/services/token-aggregation/src/services/omnl-api-catalog.ts b/services/token-aggregation/src/services/omnl-api-catalog.ts index 8d49d75..eb8c932 100644 --- a/services/token-aggregation/src/services/omnl-api-catalog.ts +++ b/services/token-aggregation/src/services/omnl-api-catalog.ts @@ -66,6 +66,13 @@ export function getOmnlApiCatalog(): { { method: 'POST', path: '/omnl/terminal/alchemy/rpc', description: 'Alchemy JSON-RPC (read/estimate methods)', body: '{ network, method, params }', auth: 'OMNL_API_KEY' }, { method: 'POST', path: '/omnl/terminal/alchemy/prices', description: 'Alchemy token prices by address', body: '{ network, addresses[] }', auth: 'OMNL_API_KEY' }, { method: 'POST', path: '/omnl/terminal/alchemy/tx-prep', description: 'Prepare unsigned ERC-20 transfer calldata via Alchemy (dry-run default)', body: '{ network, from, to, tokenAddress, amount, dryRun }', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/journal-entries', description: 'Create payout journal entry draft (IPSAS-validated pair)', auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/journal-entries/:id', description: 'Get journal entry by id', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/journal-entries/:id/post', description: 'Post journal entry to ledger path', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/journal-entries/:id/reverse', description: 'Reverse posted journal entry', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/balance-reservations', description: 'Reserve balance before payout dispatch', auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/balance-reservations/:id', description: 'Get balance reservation', auth: 'OMNL_API_KEY' }, + { method: 'DELETE', path: '/balance-reservations/:id', description: 'Release balance reservation', auth: 'OMNL_API_KEY' }, ], }; } diff --git a/services/token-aggregation/src/services/omnl-ho-liquidity-scheduler.ts b/services/token-aggregation/src/services/omnl-ho-liquidity-scheduler.ts new file mode 100644 index 0000000..ef20be9 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ho-liquidity-scheduler.ts @@ -0,0 +1,47 @@ +import { logger } from '../utils/logger'; +import { buildHoLiquiditySnapshot } from './omnl-ho-liquidity-snapshot'; + +let intervalHandle: ReturnType | null = null; + +export function startOmnlHoLiquidityScheduler(): void { + const enabled = + String(process.env.OMNL_HO_LIQUIDITY_LIVE || '1').toLowerCase() === '1' || + String(process.env.OMNL_HO_LIQUIDITY_LIVE || '').toLowerCase() === 'true'; + if (!enabled) { + return; + } + + const intervalMs = Math.max( + 60_000, + parseInt(process.env.OMNL_HO_LIQUIDITY_INTERVAL_MS || '1800000', 10) || 1_800_000, + ); + + const tick = async () => { + try { + const snap = await buildHoLiquiditySnapshot({ forceRefresh: true }); + logger.info('HO nostro liquidity cache refreshed', { + cashInBankUsd: snap.cashInBank.cashInBankUsd, + coverageRatio: snap.reserveCoverage.coverageRatio, + alerts: snap.alerts.length, + }); + } catch (error) { + logger.warn('HO nostro liquidity refresh failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + void tick(); + intervalHandle = setInterval(() => { + void tick(); + }, intervalMs); + + logger.info('HO nostro liquidity live scheduler enabled', { intervalMs }); +} + +export function stopOmnlHoLiquidityScheduler(): void { + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } +} diff --git a/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts index 44078a6..504eadb 100644 --- a/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts +++ b/services/token-aggregation/src/services/omnl-ho-liquidity-snapshot.ts @@ -1,8 +1,10 @@ import { getCachedHoReserveCoverageSnapshot, type HoReserveCoverageInfo } from './chain138-ho-reserve-coverage'; import { + evaluateSupplyNostroMatch, getCachedCashInBankAuditSnapshot, loadNostroPolicy, type CashInBankAuditSnapshot, + type SupplyNostroMatchInfo, } from './omnl-cash-in-bank-audit'; import { fineractConfigured } from './omnl-fineract-office-gl'; @@ -22,6 +24,7 @@ export interface HoLiquiditySnapshot { fineractConfigured: boolean; cashInBank: CashInBankAuditSnapshot; reserveCoverage: HoReserveCoverageInfo; + aggregateSupplyNostroMatch?: SupplyNostroMatchInfo; alerts: HoLiquidityAlert[]; auditMaxAgeMinutes: number; } @@ -60,6 +63,15 @@ export function evaluateHoLiquidityAlerts(input: { severity: 'critical', message: `Reserve coverage ${reserveCoverage.coverageRatio.toFixed(2)}× below 1.0× required`, }); + } else if ( + reserveCoverage.coverageRatio > 0 + && reserveCoverage.coverageRatio < Number(process.env.OMNL_HO_RESERVE_COVERAGE_WARN_RATIO || 1.2) + ) { + alerts.push({ + code: 'RESERVE_COVERAGE_LOW', + severity: 'warning', + message: `Reserve coverage ${reserveCoverage.coverageRatio.toFixed(2)}× below ${Number(process.env.OMNL_HO_RESERVE_COVERAGE_WARN_RATIO || 1.2)}× policy target`, + }); } const ageMin = (Date.now() - new Date(generatedAt).getTime()) / 60_000; @@ -82,6 +94,27 @@ export function evaluateHoLiquidityAlerts(input: { return alerts; } +function patchCashInBankAuditWithAggregateMatch( + cashInBank: CashInBankAuditSnapshot, + aggregateMatch: SupplyNostroMatchInfo, +): CashInBankAuditSnapshot { + const supplyStatus = aggregateMatch.status === 'matched' + ? 'aligned' + : aggregateMatch.status === 'break' + ? 'breaks' + : cashInBank.audit.status; + return { + ...cashInBank, + audit: { + ...cashInBank.audit, + status: supplyStatus, + supplyNostroMatch: aggregateMatch.status, + breaksCount: aggregateMatch.status === 'break' ? 1 : 0, + }, + supplyNostroMatch: aggregateMatch, + }; +} + export async function buildHoLiquiditySnapshot(options?: { officeId?: number; forceRefresh?: boolean; @@ -102,9 +135,16 @@ export async function buildHoLiquiditySnapshot(options?: { getCachedHoReserveCoverageSnapshot({ officeId, forceRefresh }), ]); + const aggregateSupplyNostroMatch = evaluateSupplyNostroMatch({ + totalSupplyUsd: reserveCoverage.issuedCStarUsd, + externalNostroCashUsd: cashInBank.cashInBankUsd, + headOfficeId: officeId, + }); + const cashInBankEnriched = patchCashInBankAuditWithAggregateMatch(cashInBank, aggregateSupplyNostroMatch); + const generatedAt = new Date( Math.max( - new Date(cashInBank.generatedAt).getTime(), + new Date(cashInBankEnriched.generatedAt).getTime(), new Date(reserveCoverage.generatedAt).getTime(), ), ).toISOString(); @@ -115,9 +155,10 @@ export async function buildHoLiquiditySnapshot(options?: { policyId: policy.policyId, policyRef: policy.policyRef, fineractConfigured: fineractConfigured(), - cashInBank, + cashInBank: cashInBankEnriched, reserveCoverage, - alerts: evaluateHoLiquidityAlerts({ cashInBank, reserveCoverage, generatedAt }), + aggregateSupplyNostroMatch, + alerts: evaluateHoLiquidityAlerts({ cashInBank: cashInBankEnriched, reserveCoverage, generatedAt }), auditMaxAgeMinutes: auditMaxAgeMinutes(), }; } diff --git a/services/token-aggregation/src/services/omnl-payout-journal.test.ts b/services/token-aggregation/src/services/omnl-payout-journal.test.ts new file mode 100644 index 0000000..13c55f6 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-payout-journal.test.ts @@ -0,0 +1,38 @@ +import { + createJournalEntry, + postJournalEntry, + createBalanceReservation, + releaseBalanceReservation, + clearPayoutJournalStores, +} from './omnl-payout-journal'; + +describe('omnl payout journal', () => { + beforeEach(() => { + clearPayoutJournalStores(); + }); + + it('creates and posts journal entry', () => { + const je = createJournalEntry({ + instructionId: 'PAY-1', + debitGlCode: '1000', + creditGlCode: '2000', + amount: '100.00', + currency: 'USD', + }); + expect(je.status).toBe('DRAFT'); + const posted = postJournalEntry(je.id); + expect(posted.status).toBe('POSTED'); + }); + + it('creates and releases balance reservation', () => { + const r = createBalanceReservation({ + instructionId: 'PAY-2', + amount: '50.00', + currency: 'USD', + accountRef: 'ACCT-1', + }); + expect(r.status).toBe('HELD'); + const released = releaseBalanceReservation(r.id); + expect(released.status).toBe('RELEASED'); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-payout-journal.ts b/services/token-aggregation/src/services/omnl-payout-journal.ts new file mode 100644 index 0000000..91fdc38 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-payout-journal.ts @@ -0,0 +1,157 @@ +import { randomUUID } from 'crypto'; +import { + loadIpsasRegistry, + validateJournalPairWithMatrix, +} from './omnl-ipsas-gl'; +import { loadJournalMatrix } from './omnl-journal-matrix'; + +export interface JournalLine { + debitGlCode: string; + creditGlCode: string; + amount: string; + currency: string; + narrative?: string; +} + +export interface JournalEntryRecord { + id: string; + instructionId?: string; + status: 'DRAFT' | 'POSTED' | 'REVERSED'; + lines: JournalLine[]; + ipsasRef?: string; + createdAt: string; + postedAt?: string; +} + +export interface BalanceReservationRecord { + id: string; + instructionId: string; + amount: string; + currency: string; + accountRef: string; + status: 'HELD' | 'RELEASED' | 'CONSUMED'; + createdAt: string; + releasedAt?: string; +} + +const journalStore = new Map(); +const reservationStore = new Map(); + +function loadMatrixSafe() { + try { + return loadJournalMatrix(); + } catch { + return null; + } +} + +export function createJournalEntry(body: { + instructionId?: string; + debitGlCode?: string; + creditGlCode?: string; + amount?: string; + currency?: string; + narrative?: string; + lines?: JournalLine[]; +}): JournalEntryRecord { + const lines: JournalLine[] = + body.lines ?? + (body.debitGlCode && body.creditGlCode + ? [ + { + debitGlCode: body.debitGlCode, + creditGlCode: body.creditGlCode, + amount: String(body.amount ?? '0'), + currency: String(body.currency ?? 'USD'), + narrative: body.narrative, + }, + ] + : []); + + if (!lines.length) { + throw new Error('journal entry requires lines[] or debitGlCode/creditGlCode'); + } + + const registry = loadIpsasRegistry(); + const matrix = loadMatrixSafe(); + for (const line of lines) { + const v = validateJournalPairWithMatrix(registry, matrix, line.debitGlCode, line.creditGlCode); + if (!v.valid) { + throw new Error(`IPSAS pair invalid: ${line.debitGlCode}/${line.creditGlCode}`); + } + } + + const id = randomUUID(); + const record: JournalEntryRecord = { + id, + instructionId: body.instructionId, + status: 'DRAFT', + lines, + ipsasRef: validateJournalPairWithMatrix(registry, matrix, lines[0].debitGlCode, lines[0].creditGlCode).ipsasRef, + createdAt: new Date().toISOString(), + }; + journalStore.set(id, record); + return record; +} + +export function postJournalEntry(id: string): JournalEntryRecord { + const record = journalStore.get(id); + if (!record) throw new Error('journal entry not found'); + if (record.status === 'POSTED') return record; + record.status = 'POSTED'; + record.postedAt = new Date().toISOString(); + journalStore.set(id, record); + return record; +} + +export function reverseJournalEntry(id: string): JournalEntryRecord { + const record = journalStore.get(id); + if (!record) throw new Error('journal entry not found'); + record.status = 'REVERSED'; + journalStore.set(id, record); + return record; +} + +export function getJournalEntry(id: string): JournalEntryRecord | undefined { + return journalStore.get(id); +} + +export function createBalanceReservation(body: { + instructionId: string; + amount: string; + currency: string; + accountRef?: string; + debtorId?: string; +}): BalanceReservationRecord { + const id = randomUUID(); + const record: BalanceReservationRecord = { + id, + instructionId: body.instructionId, + amount: String(body.amount), + currency: String(body.currency).toUpperCase(), + accountRef: body.accountRef ?? body.debtorId ?? 'default', + status: 'HELD', + createdAt: new Date().toISOString(), + }; + reservationStore.set(id, record); + return record; +} + +export function releaseBalanceReservation(id: string): BalanceReservationRecord { + const record = reservationStore.get(id); + if (!record) throw new Error('reservation not found'); + record.status = 'RELEASED'; + record.releasedAt = new Date().toISOString(); + reservationStore.set(id, record); + return record; +} + +export function getBalanceReservation(id: string): BalanceReservationRecord | undefined { + return reservationStore.get(id); +} + +/** Test helper */ +export function clearPayoutJournalStores(): void { + journalStore.clear(); + reservationStore.clear(); +}