From 9b3059beeb478ae98a9077c099bbe267b1550a10 Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Mon, 29 Jun 2026 08:14:47 -0700 Subject: [PATCH] fix: IPSAS double-entry compliance matrix for M0-M4 journals Replace invalid same-GL pairs (2000/2000) with proper Dr/Cr legs, add M2-M4 and inter-office entries (1410/2410), extend IPSAS registry accounts, and reject same-account pairs at validation load time. Co-authored-by: Cursor --- .../offices/office-24-opening-journal.v1.json | 2 +- config/omnl-ipsas-gl-registry.json | 56 +++++- config/omnl-journal-matrix.json | 187 +++++++++++++++--- .../src/api/routes/omnl-ipsas.ts | 4 +- .../src/services/omnl-ipsas-gl.ts | 6 + .../src/services/omnl-journal-matrix.ts | 20 +- 6 files changed, 241 insertions(+), 34 deletions(-) diff --git a/config/offices/office-24-opening-journal.v1.json b/config/offices/office-24-opening-journal.v1.json index 9bd7676..279df33 100644 --- a/config/offices/office-24-opening-journal.v1.json +++ b/config/offices/office-24-opening-journal.v1.json @@ -11,7 +11,7 @@ "headOfficeDebitGlCode": "2100", "headOfficeCreditGlCode": "2410", "materialOpeningUsd": 1000000000, - "journalMatrixDoc": "docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json", + "journalMatrixDoc": "config/omnl-journal-matrix.json", "runbookDoc": "docs/04-configuration/mifos-omnl-central-bank/HOSPITALLERS24_OFFICE_OPENING_1B_M1.md", "currency": "USD", "ipsasRef": "IPSAS 28", diff --git a/config/omnl-ipsas-gl-registry.json b/config/omnl-ipsas-gl-registry.json index 11109ce..21e7891 100644 --- a/config/omnl-ipsas-gl-registry.json +++ b/config/omnl-ipsas-gl-registry.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "IPSAS-aligned GL codes for OMNL Hybx (Fineract). Includes core M0/M1, FX/XAU (IAS 21), fees, and IAS 37 provisions.", - "version": "1.1.0", + "version": "1.2.0", "currencyCode": "USD", "accounts": [ { @@ -22,6 +22,42 @@ "ifrsRefs": ["IAS 32", "IFRS 9"], "roles": ["m0_reserve", "treasury_reserve"] }, + { + "glCode": "1410", + "name": "Due from Head Office (inter-office receivable)", + "fineractType": "ASSET", + "usage": "Branch receivable from HO for M1 allocation; not M4 near-money aggregate", + "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IFRS 7"], + "roles": ["interoffice_receivable", "due_from_ho"] + }, + { + "glCode": "2200", + "name": "M2 — Broad money / meta-fiat rail", + "fineractType": "LIABILITY", + "usage": "Broad money layer backing token loads and external rails", + "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 32"], + "roles": ["m2_broad", "meta_fiat"] + }, + { + "glCode": "2300", + "name": "M3 — On-chain token liabilities", + "fineractType": "LIABILITY", + "usage": "Token liabilities minted from M2 backing on Chain 138", + "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IAS 32", "IFRS 9"], + "roles": ["m3_token_liability", "crypto_liability"] + }, + { + "glCode": "2410", + "name": "Due to branch offices (inter-office payable)", + "fineractType": "LIABILITY", + "usage": "HO payable to branch for allocated M1 / opening journals", + "ipsasStandards": ["IPSAS 28", "IPSAS 29"], + "ifrsRefs": ["IFRS 7"], + "roles": ["interoffice_payable", "due_to_branch"] + }, { "glCode": "12010", "name": "FX reserves — USD", @@ -184,8 +220,19 @@ "allowedJournalPairs": [ { "debitGlCode": "1000", "creditGlCode": "2000", "ipsasRef": "IPSAS 3, 28", "memo": "T-001", "ifrsRef": "IAS 32 issuance" }, { "debitGlCode": "1050", "creditGlCode": "2000", "ipsasRef": "IPSAS 28, 29", "memo": "T-001B", "ifrsRef": "IAS 32 M0 reserve" }, - { "debitGlCode": "2000", "creditGlCode": "2000", "ipsasRef": "IPSAS 28", "memo": "T-002A" }, + { "debitGlCode": "2000", "creditGlCode": "2410", "ipsasRef": "IPSAS 28", "memo": "T-002A", "ifrsRef": "IAS 32 inter-office allocation" }, { "debitGlCode": "2000", "creditGlCode": "2100", "ipsasRef": "IPSAS 28", "memo": "T-002B" }, + { "debitGlCode": "2100", "creditGlCode": "2410", "ipsasRef": "IPSAS 28", "memo": "T-H24-HO", "ifrsRef": "IAS 32 HO opening leg" }, + { "debitGlCode": "1410", "creditGlCode": "2100", "ipsasRef": "IPSAS 28", "memo": "T-H24-BR", "ifrsRef": "IAS 32 branch opening leg" }, + { "debitGlCode": "2100", "creditGlCode": "2200", "ipsasRef": "IPSAS 28, 29", "memo": "T-M1M2", "ifrsRef": "IAS 32 M1 to M2" }, + { "debitGlCode": "2200", "creditGlCode": "2300", "ipsasRef": "IPSAS 28, 29", "memo": "T-M2M3", "ifrsRef": "IAS 32 token liability" }, + { "debitGlCode": "2200", "creditGlCode": "1000", "ipsasRef": "IPSAS 2, 28", "memo": "T-M2M4", "ifrsRef": "IFRS 7 SWIFT suspense" }, + { "debitGlCode": "2100", "creditGlCode": "1000", "ipsasRef": "IPSAS 2, 28", "memo": "T-M1M4", "ifrsRef": "IFRS 7 SWIFT suspense" }, + { "debitGlCode": "1000", "creditGlCode": "2200", "ipsasRef": "IPSAS 2, 28", "memo": "T-M4-IN", "ifrsRef": "IFRS 7 inbound SWIFT" }, + { "debitGlCode": "1410", "creditGlCode": "2100", "ipsasRef": "IPSAS 28, 29", "memo": "T-SBLC", "ifrsRef": "IFRS 9 trade finance" }, + { "debitGlCode": "1410", "creditGlCode": "1000", "ipsasRef": "IPSAS 2, 28", "memo": "T-DLC", "ifrsRef": "IFRS 9 documentary LC" }, + { "debitGlCode": "1410", "creditGlCode": "2100", "ipsasRef": "IPSAS 28, 29", "memo": "T-BG", "ifrsRef": "IFRS 9 bank guarantee" }, + { "debitGlCode": "1000", "creditGlCode": "2200", "ipsasRef": "IPSAS 2, 28", "memo": "T-LS", "ifrsRef": "IFRS 7 letter of settlement" }, { "debitGlCode": "12020", "creditGlCode": "42100", "ipsasRef": "IPSAS 29", "memo": "FX-REVAL-GAIN", "ifrsRef": "IAS 21 revaluation" }, { "debitGlCode": "52100", "creditGlCode": "12020", "ipsasRef": "IPSAS 29", "memo": "FX-REVAL-LOSS", "ifrsRef": "IAS 21 revaluation" }, { "debitGlCode": "52100", "creditGlCode": "23010", "ipsasRef": "IPSAS 19", "memo": "IAS37-PROVISION", "ifrsRef": "IAS 37" }, @@ -216,6 +263,11 @@ "monetaryLayerHints": { "m0_reserve": { "primaryGlCodes": ["1050"], "ipsasNarrative": "Treasury / M0 reserve assets (IPSAS 28, 29)" }, "m1_liability": { "primaryGlCodes": ["2000", "2100"], "ipsasNarrative": "Financial liabilities — deposits (IPSAS 28, 29)" }, + "m2_broad": { "primaryGlCodes": ["2200"], "ipsasNarrative": "Broad money / meta-fiat rail (IPSAS 28, 29)" }, + "m3_token": { "primaryGlCodes": ["2300"], "ipsasNarrative": "On-chain token liabilities (IPSAS 28, 29)" }, + "m4_near_money": { "primaryGlCodes": ["1000"], "ipsasNarrative": "Settlement suspense + in-flight SWIFT — not GL 1410 (IPSAS 2, 28)" }, + "interoffice_receivable": { "primaryGlCodes": ["1410"], "ipsasNarrative": "Due from Head Office — branch asset (IPSAS 28)" }, + "interoffice_payable": { "primaryGlCodes": ["2410"], "ipsasNarrative": "Due to branch — HO liability (IPSAS 28)" }, "m00_gru": { "primaryGlCodes": ["21010"], "ipsasNarrative": "GRU M00 base reserve (gold-referenced)" }, "settlement": { "primaryGlCodes": ["1000", "13010"], "ipsasNarrative": "Cash and cash equivalents (IPSAS 2)" }, "equity": { "primaryGlCodes": ["3000"], "ipsasNarrative": "Equity / control (IPSAS 1)" }, diff --git a/config/omnl-journal-matrix.json b/config/omnl-journal-matrix.json index cddaf0a..c94d59c 100644 --- a/config/omnl-journal-matrix.json +++ b/config/omnl-journal-matrix.json @@ -1,9 +1,10 @@ { - "description": "OMNL journal entries for API posting to OMNL Hybx (Fineract). Head Office and entities 2–8 per Migration Memorandum. officeId=1 for all; narrative identifies entity. IPSAS-aligned.", - "source": "OMNL_JOURNAL_LEDGER_MATRIX.md", + "description": "IPSAS double-entry journal matrix for OMNL Hybx (Fineract). Every entry debits one GL and credits a distinct GL with equal amount — no same-account pairs. Head Office (officeId=1) and branch legs identified by memo / officeId.", + "source": "OMNL_IPSAS_COMPLIANCE_MATRIX", "currencyCode": "USD", "dateFormat": "yyyy-MM-dd", "locale": "en", + "doubleEntryRule": "debitGlCode !== creditGlCode; sum(debits) === sum(credits) per journal", "entries": [ { "memo": "T-001", @@ -11,8 +12,9 @@ "debitGlCode": "1000", "creditGlCode": "2000", "amount": 900000000000, - "narrative": "Opening Balance Migration (Head Office)", - "ipsasRef": "IPSAS 3, 28" + "narrative": "Opening Balance Migration (Head Office) — Dr settlement asset / Cr M1 central deposits", + "ipsasRef": "IPSAS 3, 28", + "moneyLayer": "M1" }, { "memo": "T-001B", @@ -20,17 +22,19 @@ "debitGlCode": "1050", "creditGlCode": "2000", "amount": 250000000000, - "narrative": "Treasury Conversion — Transfer to Reserve (M0); Head Office", - "ipsasRef": "IPSAS 28, 29" + "narrative": "Treasury Conversion — Dr M0 reserve / Cr M1 central deposits (Head Office)", + "ipsasRef": "IPSAS 28, 29", + "moneyLayer": "M0" }, { "memo": "T-002A", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 2900000000, - "narrative": "Shamrayan Available (M1) — Office 2", - "ipsasRef": "IPSAS 28" + "narrative": "HO M1 allocation to Office 2 — Dr central deposits / Cr due to branch", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-002B", @@ -38,8 +42,9 @@ "debitGlCode": "2000", "creditGlCode": "2100", "amount": 2100000000, - "narrative": "Shamrayan Restricted — Office 2", - "ipsasRef": "IPSAS 28" + "narrative": "Shamrayan Restricted — Dr M1 central / Cr M1 restricted (Office 2)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-003", @@ -47,53 +52,179 @@ "debitGlCode": "2000", "creditGlCode": "2100", "amount": 350000000000, - "narrative": "HYBX Capitalization Escrow — Office 3", - "ipsasRef": "IPSAS 28" + "narrative": "HYBX Capitalization Escrow — Dr M1 central / Cr M1 restricted (Office 3)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-004", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 5000000000, - "narrative": "TAJ Allocation (M1) — Office 4", - "ipsasRef": "IPSAS 28" + "narrative": "TAJ Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 4)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-005", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 5000000000, - "narrative": "Aseret Allocation (M1) — Office 5", - "ipsasRef": "IPSAS 28" + "narrative": "Aseret Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 5)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-006", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 5000000000, - "narrative": "Mann Li Allocation (M1) — Office 6", - "ipsasRef": "IPSAS 28" + "narrative": "Mann Li Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 6)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-007", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 50000000000, - "narrative": "OSJ Allocation (M1) — Office 7", - "ipsasRef": "IPSAS 28" + "narrative": "OSJ Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 7)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" }, { "memo": "T-008", "officeId": 1, "debitGlCode": "2000", - "creditGlCode": "2000", + "creditGlCode": "2410", "amount": 50000000000, - "narrative": "Alltra Allocation (M1) — Office 8", - "ipsasRef": "IPSAS 28" + "narrative": "Alltra Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 8)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-H24-HO", + "officeId": 1, + "debitGlCode": "2100", + "creditGlCode": "2410", + "amount": 1000000000, + "narrative": "HOSPITALLERS-24 opening HO leg — Dr M1 circulating / Cr due to Office 24", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-H24-BR", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 1000000000, + "narrative": "HOSPITALLERS-24 opening branch leg — Dr due from HO / Cr M1 circulating", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-M1M2", + "officeId": 24, + "debitGlCode": "2100", + "creditGlCode": "2200", + "amount": 0, + "narrative": "M1→M2 broad-money conversion — Dr M1 circulating / Cr M2 broad money", + "ipsasRef": "IPSAS 28, 29", + "moneyLayer": "M2" + }, + { + "memo": "T-M2M3", + "officeId": 24, + "debitGlCode": "2200", + "creditGlCode": "2300", + "amount": 0, + "narrative": "M2→M3 token load — Dr M2 broad money / Cr M3 on-chain token liability", + "ipsasRef": "IPSAS 28, 29", + "moneyLayer": "M3" + }, + { + "memo": "T-M2M4", + "officeId": 24, + "debitGlCode": "2200", + "creditGlCode": "1000", + "amount": 0, + "narrative": "M2→M4 SWIFT/RTGS outbound — Dr M2 broad money / Cr settlement suspense (GL 1000)", + "ipsasRef": "IPSAS 2, 28", + "moneyLayer": "M4" + }, + { + "memo": "T-M1M4", + "officeId": 24, + "debitGlCode": "2100", + "creditGlCode": "1000", + "amount": 0, + "narrative": "M1→M4 SWIFT/RTGS outbound — Dr M1 circulating / Cr settlement suspense (GL 1000)", + "ipsasRef": "IPSAS 2, 28", + "moneyLayer": "M4" + }, + { + "memo": "T-M4-IN", + "officeId": 24, + "debitGlCode": "1000", + "creditGlCode": "2200", + "amount": 0, + "narrative": "M4 inbound SWIFT settled — Dr settlement suspense / Cr M2 broad money", + "ipsasRef": "IPSAS 2, 28", + "moneyLayer": "M4" + }, + { + "memo": "T-SBLC", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 0, + "narrative": "SBLC trade finance — Dr inter-office / contingent receivable / Cr M1", + "ipsasRef": "IPSAS 28, 29", + "moneyLayer": "M4" + }, + { + "memo": "T-DLC", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "1000", + "amount": 0, + "narrative": "Documentary LC — Dr contingent receivable / Cr M4 settlement suspense", + "ipsasRef": "IPSAS 2, 28", + "moneyLayer": "M4" + }, + { + "memo": "T-BG", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 0, + "narrative": "Bank guarantee — Dr contingent receivable / Cr M1", + "ipsasRef": "IPSAS 28, 29", + "moneyLayer": "M4" + }, + { + "memo": "T-LS", + "officeId": 24, + "debitGlCode": "1000", + "creditGlCode": "2200", + "amount": 0, + "narrative": "Letter of settlement — Dr M4 suspense / Cr M2", + "ipsasRef": "IPSAS 2, 28", + "moneyLayer": "M4" + }, + { + "memo": "SETTLE-NOSTRO", + "officeId": 1, + "debitGlCode": "13010", + "creditGlCode": "2000", + "amount": 0, + "narrative": "Nostro settlement — Dr FX nostro asset / Cr M1 central deposits", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" } ] } diff --git a/services/token-aggregation/src/api/routes/omnl-ipsas.ts b/services/token-aggregation/src/api/routes/omnl-ipsas.ts index 4deb329..3012385 100644 --- a/services/token-aggregation/src/api/routes/omnl-ipsas.ts +++ b/services/token-aggregation/src/api/routes/omnl-ipsas.ts @@ -31,7 +31,7 @@ router.get('/omnl/ipsas/registry', omnlSensitiveRouteGuard, (_req: Request, res: }); /** - * GET /omnl/ipsas/matrix — journal matrix (T-001…T-008) for Fineract posting alignment. + * GET /omnl/ipsas/matrix — IPSAS double-entry journal matrix (T-001…T-H24, M0–M4 rails). */ router.get('/omnl/ipsas/matrix', omnlSensitiveRouteGuard, (_req: Request, res: Response) => { try { @@ -213,7 +213,7 @@ router.get('/omnl/ipsas/compliance-context/:lineId', omnlSensitiveRouteGuard, as ipsas: { monetaryLayerHints: registry.monetaryLayerHints, postingGuidance: - 'Reserve movements (M0) map to GL 1050; M1 liabilities to 2000/2100; settlement to 1000. Validate every journal with GET /api/v1/omnl/ipsas/validate-pair before POST /journalentries.', + 'IPSAS double-entry: every journal must debit one GL and credit a distinct GL (debitGlCode !== creditGlCode). M0→1050, M1→2000/2100, M2→2200, M3→2300, M4 suspense→1000, inter-office→1410/2410. Validate pairs via GET /api/v1/omnl/ipsas/validate-pair or POST /api/v1/omnl/ipsas/validate-pairs before POST /journalentries.', fineractDocs: 'https://omnl.hybxfinance.io/fineract-provider/swagger-ui/index.html', }, }); diff --git a/services/token-aggregation/src/services/omnl-ipsas-gl.ts b/services/token-aggregation/src/services/omnl-ipsas-gl.ts index 909f550..bfe557f 100644 --- a/services/token-aggregation/src/services/omnl-ipsas-gl.ts +++ b/services/token-aggregation/src/services/omnl-ipsas-gl.ts @@ -52,6 +52,9 @@ export function validateJournalPair( ): { valid: boolean; ipsasRef?: string; memo?: string } { const d = debitGlCode.trim(); const c = creditGlCode.trim(); + if (d === c) { + return { valid: false }; + } const hit = registry.allowedJournalPairs.find( (p) => p.debitGlCode === d && p.creditGlCode === c ); @@ -74,6 +77,9 @@ export function validateJournalPairWithMatrix( } const d = debitGlCode.trim(); const c = creditGlCode.trim(); + if (d === c) { + return { valid: false, source: 'none' }; + } if (matrix) { const hit = matrix.entries.find((e) => e.debitGlCode === d && e.creditGlCode === c); if (hit) { diff --git a/services/token-aggregation/src/services/omnl-journal-matrix.ts b/services/token-aggregation/src/services/omnl-journal-matrix.ts index a49590c..7ab6a53 100644 --- a/services/token-aggregation/src/services/omnl-journal-matrix.ts +++ b/services/token-aggregation/src/services/omnl-journal-matrix.ts @@ -9,6 +9,7 @@ export interface JournalMatrixEntry { amount: number; narrative: string; ipsasRef: string; + moneyLayer?: string; } export interface JournalMatrixFile { @@ -16,6 +17,7 @@ export interface JournalMatrixFile { description?: string; source?: string; currencyCode?: string; + doubleEntryRule?: string; } export function loadJournalMatrixPath(): string { @@ -30,7 +32,23 @@ export function loadJournalMatrix(): JournalMatrixFile { if (!existsSync(p)) { throw new Error(`Journal matrix not found: ${p}. Set OMNL_JOURNAL_MATRIX_PATH.`); } - return JSON.parse(readFileSync(p, 'utf8')) as JournalMatrixFile; + const matrix = JSON.parse(readFileSync(p, 'utf8')) as JournalMatrixFile; + const violations = validateMatrixDoubleEntry(matrix); + if (violations.length > 0) { + throw new Error(`IPSAS journal matrix invalid: ${violations.join('; ')}`); + } + return matrix; +} + +/** IPSAS requires distinct debit/credit GL per line (true double entry). */ +export function validateMatrixDoubleEntry(matrix: JournalMatrixFile): string[] { + const errors: string[] = []; + for (const e of matrix.entries) { + if (e.debitGlCode.trim() === e.creditGlCode.trim()) { + errors.push(`${e.memo}: debit and credit GL must differ (${e.debitGlCode})`); + } + } + return errors; } /** Union of IPSAS registry pairs and matrix-derived pairs for validation. */