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();
+}