fix: IPSAS double-entry compliance matrix for M0-M4 journals
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m36s
CI/CD Pipeline / Security Scanning (push) Successful in 2m26s
CI/CD Pipeline / Lint and Format (push) Failing after 49s
CI/CD Pipeline / Terraform Validation (push) Failing after 26s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 24s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 30s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 27s
Validation / validate-genesis (push) Successful in 30s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 11s
Validation / validate-smart-contracts (push) Failing after 10s
Validation / validate-security (push) Failing after 1m23s
Validation / validate-documentation (push) Failing after 19s

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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 08:14:47 -07:00
parent 771697563e
commit 9b3059beeb
6 changed files with 241 additions and 34 deletions

View File

@@ -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",

View File

@@ -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)" },

View File

@@ -1,9 +1,10 @@
{
"description": "OMNL journal entries for API posting to OMNL Hybx (Fineract). Head Office and entities 28 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"
}
]
}

View File

@@ -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, M0M4 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',
},
});

View File

@@ -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) {

View File

@@ -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. */