Add HO liquidity scheduler, snapshot enrichments, and compliance console panel.
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m27s
CI/CD Pipeline / Security Scanning (push) Successful in 2m37s
CI/CD Pipeline / Lint and Format (push) Failing after 53s
CI/CD Pipeline / Terraform Validation (push) Failing after 31s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 37s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 33s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m26s
Validation / validate-documentation (push) Failing after 19s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m27s
CI/CD Pipeline / Security Scanning (push) Successful in 2m37s
CI/CD Pipeline / Lint and Format (push) Failing after 53s
CI/CD Pipeline / Terraform Validation (push) Failing after 31s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 37s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 33s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 11s
Validation / validate-security (push) Failing after 1m26s
Validation / validate-documentation (push) Failing after 19s
Refreshes ho-liquidity-snapshot every 30 minutes with aggregate supplyNostroMatch and reserve alerts; embeds nostro/reserve KPIs in the OMNL compliance console; includes payout journal routes needed for token-aggregation build. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -51,6 +51,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section card">
|
||||
<h2>HO nostro & reserve coverage</h2>
|
||||
<div id="ho-liquidity-kv"></div>
|
||||
</section>
|
||||
|
||||
<section class="section card">
|
||||
<h2>Triple-state reconcile</h2>
|
||||
<div id="triple-summary"></div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { buildHoLiquiditySnapshot } from './omnl-ho-liquidity-snapshot';
|
||||
|
||||
let intervalHandle: ReturnType<typeof setInterval> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
157
services/token-aggregation/src/services/omnl-payout-journal.ts
Normal file
157
services/token-aggregation/src/services/omnl-payout-journal.ts
Normal file
@@ -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<string, JournalEntryRecord>();
|
||||
const reservationStore = new Map<string, BalanceReservationRecord>();
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user