diff --git a/config/offices/office-24-opening-journal.v1.json b/config/offices/office-24-opening-journal.v1.json index 279df33..9bd7676 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": "config/omnl-journal-matrix.json", + "journalMatrixDoc": "docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.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 21e7891..b06c64e 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.2.0", + "version": "1.2.1", "currencyCode": "USD", "accounts": [ { @@ -117,18 +117,18 @@ }, { "glCode": "2000", - "name": "USD Central Deposits (M1)", + "name": "USD Central Deposits (M1 pool)", "fineractType": "LIABILITY", - "usage": "Demand deposits; financial liability", + "usage": "Head Office central M1 pool / migration control; Phase C spendable M1 at branches uses 2100", "ipsasStandards": ["IPSAS 28", "IPSAS 29"], "ifrsRefs": ["IAS 32", "IFRS 7"], "roles": ["m1_liability", "demand_deposit"] }, { "glCode": "2100", - "name": "USD Restricted / Escrow (M1)", + "name": "USD M1 Circulating (branch spendable)", "fineractType": "LIABILITY", - "usage": "Restricted liabilities; escrow / fiduciary", + "usage": "Branch spendable M1 / circulating eMoney (Phase C, Office 24 glM1); interoffice HO leg debits this account", "ipsasStandards": ["IPSAS 28", "IPSAS 29"], "ifrsRefs": ["IAS 32"], "roles": ["m1_restricted", "escrow", "fiduciary"] @@ -220,7 +220,8 @@ "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": "2410", "ipsasRef": "IPSAS 28", "memo": "T-002A", "ifrsRef": "IAS 32 inter-office allocation" }, + { "debitGlCode": "2100", "creditGlCode": "2410", "ipsasRef": "IPSAS 28", "memo": "INTEROFFICE-HO", "ifrsRef": "IAS 32 Phase C HO leg (2100→2410)" }, + { "debitGlCode": "1410", "creditGlCode": "2100", "ipsasRef": "IPSAS 28", "memo": "INTEROFFICE-BR", "ifrsRef": "IAS 32 Phase C branch leg (1410→2100)" }, { "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" }, diff --git a/config/omnl-journal-matrix.json b/config/omnl-journal-matrix.json index c94d59c..6b3ab4d 100644 --- a/config/omnl-journal-matrix.json +++ b/config/omnl-journal-matrix.json @@ -1,10 +1,11 @@ { - "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.", + "description": "IPSAS double-entry journal matrix for OMNL Hybx (Fineract). Every entry debits one GL and credits a distinct GL. Phase C interoffice: HO leg 2100→2410, branch leg 1410→2100. Served path: smom-dbis-138/config/omnl-journal-matrix.json (override OMNL_JOURNAL_MATRIX_PATH).", "source": "OMNL_IPSAS_COMPLIANCE_MATRIX", "currencyCode": "USD", "dateFormat": "yyyy-MM-dd", "locale": "en", "doubleEntryRule": "debitGlCode !== creditGlCode; sum(debits) === sum(credits) per journal", + "phaseCInterofficeRule": "HO officeId=1: Dr 2100 M1 circulating / Cr 2410 due to branch; branch office: Dr 1410 due from HO / Cr 2100 M1 circulating", "entries": [ { "memo": "T-001", @@ -12,7 +13,7 @@ "debitGlCode": "1000", "creditGlCode": "2000", "amount": 900000000000, - "narrative": "Opening Balance Migration (Head Office) — Dr settlement asset / Cr M1 central deposits", + "narrative": "Opening Balance Migration (Head Office) — Dr settlement asset / Cr M1 central pool", "ipsasRef": "IPSAS 3, 28", "moneyLayer": "M1" }, @@ -22,17 +23,27 @@ "debitGlCode": "1050", "creditGlCode": "2000", "amount": 250000000000, - "narrative": "Treasury Conversion — Dr M0 reserve / Cr M1 central deposits (Head Office)", + "narrative": "Treasury Conversion — Dr M0 reserve / Cr M1 central pool (Head Office)", "ipsasRef": "IPSAS 28, 29", "moneyLayer": "M0" }, { - "memo": "T-002A", + "memo": "T-002A-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 2900000000, - "narrative": "HO M1 allocation to Office 2 — Dr central deposits / Cr due to branch", + "narrative": "Phase C HO leg — Shamrayan Available (M1) Office 2: Dr M1 circulating / Cr due to branch", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-002A-BR", + "officeId": 2, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 2900000000, + "narrative": "Phase C branch leg — Shamrayan Available (M1) Office 2: Dr due from HO / Cr M1 circulating", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, @@ -42,7 +53,7 @@ "debitGlCode": "2000", "creditGlCode": "2100", "amount": 2100000000, - "narrative": "Shamrayan Restricted — Dr M1 central / Cr M1 restricted (Office 2)", + "narrative": "Shamrayan Restricted — Dr M1 central pool / Cr M1 restricted escrow (Office 2)", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, @@ -52,57 +63,107 @@ "debitGlCode": "2000", "creditGlCode": "2100", "amount": 350000000000, - "narrative": "HYBX Capitalization Escrow — Dr M1 central / Cr M1 restricted (Office 3)", + "narrative": "HYBX Capitalization Escrow — Dr M1 central pool / Cr M1 restricted (Office 3)", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, { - "memo": "T-004", + "memo": "T-004-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 5000000000, - "narrative": "TAJ Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 4)", + "narrative": "Phase C HO leg — TAJ Allocation (M1) Office 4", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, { - "memo": "T-005", + "memo": "T-004-BR", + "officeId": 4, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 5000000000, + "narrative": "Phase C branch leg — TAJ Allocation (M1) Office 4", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-005-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 5000000000, - "narrative": "Aseret Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 5)", + "narrative": "Phase C HO leg — Aseret Allocation (M1) Office 5", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, { - "memo": "T-006", + "memo": "T-005-BR", + "officeId": 5, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 5000000000, + "narrative": "Phase C branch leg — Aseret Allocation (M1) Office 5", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-006-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 5000000000, - "narrative": "Mann Li Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 6)", + "narrative": "Phase C HO leg — Mann Li Allocation (M1) Office 6", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, { - "memo": "T-007", + "memo": "T-006-BR", + "officeId": 6, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 5000000000, + "narrative": "Phase C branch leg — Mann Li Allocation (M1) Office 6", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-007-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 50000000000, - "narrative": "OSJ Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 7)", + "narrative": "Phase C HO leg — OSJ Allocation (M1) Office 7", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, { - "memo": "T-008", + "memo": "T-007-BR", + "officeId": 7, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 50000000000, + "narrative": "Phase C branch leg — OSJ Allocation (M1) Office 7", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-008-HO", "officeId": 1, - "debitGlCode": "2000", + "debitGlCode": "2100", "creditGlCode": "2410", "amount": 50000000000, - "narrative": "Alltra Allocation (M1) — HO Dr central deposits / Cr due to branch (Office 8)", + "narrative": "Phase C HO leg — Alltra Allocation (M1) Office 8", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-008-BR", + "officeId": 8, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 50000000000, + "narrative": "Phase C branch leg — Alltra Allocation (M1) Office 8", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, @@ -122,7 +183,7 @@ "debitGlCode": "1410", "creditGlCode": "2100", "amount": 1000000000, - "narrative": "HOSPITALLERS-24 opening branch leg — Dr due from HO / Cr M1 circulating", + "narrative": "HOSPITALLERS-24 opening branch leg — Dr due from HO / Cr M1 circulating (live $1B post)", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" }, @@ -182,7 +243,7 @@ "debitGlCode": "1410", "creditGlCode": "2100", "amount": 0, - "narrative": "SBLC trade finance — Dr inter-office / contingent receivable / Cr M1", + "narrative": "SBLC trade finance — Dr inter-office receivable / Cr M1", "ipsasRef": "IPSAS 28, 29", "moneyLayer": "M4" }, @@ -222,7 +283,7 @@ "debitGlCode": "13010", "creditGlCode": "2000", "amount": 0, - "narrative": "Nostro settlement — Dr FX nostro asset / Cr M1 central deposits", + "narrative": "Nostro settlement — Dr FX nostro asset / Cr M1 central pool", "ipsasRef": "IPSAS 28", "moneyLayer": "M1" } diff --git a/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json b/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json new file mode 100644 index 0000000..a8a13a8 --- /dev/null +++ b/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json @@ -0,0 +1,28 @@ +{ + "description": "HOSPITALLERS Office 24 — $1B M1 opening (two-leg). Branch leg posted live in Fineract; HO leg per Phase C.", + "source": "config/offices/office-24-opening-journal.v1.json", + "currencyCode": "USD", + "officeId": 24, + "entries": [ + { + "memo": "T-H24-HO", + "officeId": 1, + "debitGlCode": "2100", + "creditGlCode": "2410", + "amount": 1000000000, + "narrative": "HOSPITALLERS-24 opening HO leg", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-H24-BR", + "officeId": 24, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 1000000000, + "narrative": "HOSPITALLERS-24 opening branch leg (live post)", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + } + ] +} diff --git a/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-phase-c-interoffice.json b/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-phase-c-interoffice.json new file mode 100644 index 0000000..9f3afdf --- /dev/null +++ b/docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-phase-c-interoffice.json @@ -0,0 +1,27 @@ +{ + "description": "Phase C interoffice two-leg pattern (canonical). HO: 2100→2410. Branch: 1410→2100. Mirrors live HOSPITALLERS-24 $1B opening.", + "source": "config/omnl-journal-matrix.json", + "currencyCode": "USD", + "entries": [ + { + "memo": "T-002A-HO", + "officeId": 1, + "debitGlCode": "2100", + "creditGlCode": "2410", + "amount": 0, + "narrative": "Phase C HO leg template — Dr M1 circulating / Cr due to branch", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + }, + { + "memo": "T-002A-BR", + "officeId": 2, + "debitGlCode": "1410", + "creditGlCode": "2100", + "amount": 0, + "narrative": "Phase C branch leg template — Dr due from HO / Cr M1 circulating", + "ipsasRef": "IPSAS 28", + "moneyLayer": "M1" + } + ] +} diff --git a/docs/hybx-omnl/OMNL_IPSAS_API.md b/docs/hybx-omnl/OMNL_IPSAS_API.md index 71c4a42..cf1bdcf 100644 --- a/docs/hybx-omnl/OMNL_IPSAS_API.md +++ b/docs/hybx-omnl/OMNL_IPSAS_API.md @@ -11,7 +11,9 @@ Endpoints are served under **`/api/v1`** when the token-aggregation service is r | File | Purpose | |------|---------| | [`config/omnl-ipsas-gl-registry.json`](../../config/omnl-ipsas-gl-registry.json) | Canonical GL codes (1000, 1050, 2000, 2100, 3000), IPSAS references, allowed pairs, monetary-layer hints | -| [`config/omnl-journal-matrix.json`](../../config/omnl-journal-matrix.json) | T-001…T-008 journal lines | +| [`config/omnl-journal-matrix.json`](../../config/omnl-journal-matrix.json) | IPSAS double-entry matrix (Phase C interoffice, M0–M4, Office 24 opening) | +| [`docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-phase-c-interoffice.json`](../../docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-phase-c-interoffice.json) | Phase C HO/branch template | +| [`docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json`](../../docs/04-configuration/mifos-omnl-central-bank/omnl-journal-matrix-hospitallers24-opening-1b.json) | HOSPITALLERS-24 $1B opening two-leg reference | Override paths with `OMNL_IPSAS_GL_REGISTRY` and `OMNL_JOURNAL_MATRIX_PATH` if needed. @@ -64,4 +66,25 @@ Content-Type: application/json {"pairs":[{"debitGlCode":"1050","creditGlCode":"2000"}]} ``` -Expect `ipsasCompliantPair: true` when the pair is allowed in the registry or matrix. +Expect `ipsasCompliantPair: true` when the pair is allowed in the registry or matrix. Same-GL pairs (e.g. `2000/2000`) return `doubleEntryValid: false`, `rejectReason: "same_gl_account"`. + +### Post-deploy validation (token-aggregation default port 3000) + +```bash +# FAIL — same GL (invalid double entry) +curl -s "http://127.0.0.1:3000/api/v1/omnl/ipsas/validate-pair?debitGlCode=2000&creditGlCode=2000" + +# PASS — Office 24 branch leg (live $1B pattern) +curl -s "http://127.0.0.1:3000/api/v1/omnl/ipsas/validate-pair?debitGlCode=1410&creditGlCode=2100" + +# PASS — Phase C HO interoffice leg +curl -s "http://127.0.0.1:3000/api/v1/omnl/ipsas/validate-pair?debitGlCode=2100&creditGlCode=2410" + +# PASS — M2→M3 token load +curl -s "http://127.0.0.1:3000/api/v1/omnl/ipsas/validate-pair?debitGlCode=2200&creditGlCode=2300" + +# Reconcile anchor changes when registry/matrix updates +curl -s "http://127.0.0.1:3000/api/v1/omnl/reconcile-anchor" +``` + +**Phase C rule:** HO allocation uses **`2100 → 2410`** (not `2000 → 2410`). Branch leg **`1410 → 2100`**. GL `2100` is branch spendable M1 (matches Office 24 settlement profile `glM1`). diff --git a/services/token-aggregation/src/api/routes/omnl-ipsas.ts b/services/token-aggregation/src/api/routes/omnl-ipsas.ts index 3012385..d091458 100644 --- a/services/token-aggregation/src/api/routes/omnl-ipsas.ts +++ b/services/token-aggregation/src/api/routes/omnl-ipsas.ts @@ -4,6 +4,7 @@ import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middl import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; import { loadIpsasRegistry, + isDoubleEntryPair, validateJournalPairWithMatrix, fetchFineractGlAccounts, compareRegistryToFineract, @@ -78,7 +79,9 @@ router.post('/omnl/ipsas/validate-pairs', (req: Request, res: Response) => { return { debitGlCode, creditGlCode, + doubleEntryValid: debitGlCode !== creditGlCode, ipsasCompliantPair: v.valid, + rejectReason: v.rejectReason, ipsasRef: v.ipsasRef, memo: v.memo, narrative: v.narrative, @@ -107,13 +110,16 @@ router.get('/omnl/ipsas/validate-pair', (req: Request, res: Response) => { } catch { matrix = null; } + const doubleEntryValid = isDoubleEntryPair(debitGlCode, creditGlCode); const v = validateJournalPairWithMatrix(registry, matrix, debitGlCode, creditGlCode); const debitKnown = registry.accounts.some((a) => a.glCode === debitGlCode); const creditKnown = registry.accounts.some((a) => a.glCode === creditGlCode); res.json({ debitGlCode, creditGlCode, + doubleEntryValid, ipsasCompliantPair: v.valid, + rejectReason: v.rejectReason, ipsasRef: v.ipsasRef, memo: v.memo, narrative: v.narrative, diff --git a/services/token-aggregation/src/services/omnl-ipsas-gl.ts b/services/token-aggregation/src/services/omnl-ipsas-gl.ts index bfe557f..7f6e8d2 100644 --- a/services/token-aggregation/src/services/omnl-ipsas-gl.ts +++ b/services/token-aggregation/src/services/omnl-ipsas-gl.ts @@ -45,15 +45,19 @@ export function loadIpsasRegistry(): IpsasGlRegistry { return JSON.parse(readFileSync(p, 'utf8')) as IpsasGlRegistry; } +export function isDoubleEntryPair(debitGlCode: string, creditGlCode: string): boolean { + return debitGlCode.trim() !== creditGlCode.trim(); +} + export function validateJournalPair( registry: IpsasGlRegistry, debitGlCode: string, creditGlCode: string -): { valid: boolean; ipsasRef?: string; memo?: string } { +): { valid: boolean; ipsasRef?: string; memo?: string; rejectReason?: string } { const d = debitGlCode.trim(); const c = creditGlCode.trim(); - if (d === c) { - return { valid: false }; + if (!isDoubleEntryPair(d, c)) { + return { valid: false, rejectReason: 'same_gl_account' }; } const hit = registry.allowedJournalPairs.find( (p) => p.debitGlCode === d && p.creditGlCode === c @@ -70,16 +74,16 @@ export function validateJournalPairWithMatrix( matrix: JournalMatrixFile | null, debitGlCode: string, creditGlCode: string -): { valid: boolean; ipsasRef?: string; memo?: string; narrative?: string; source: 'registry' | 'matrix' | 'none' } { +): { valid: boolean; ipsasRef?: string; memo?: string; narrative?: string; source: 'registry' | 'matrix' | 'none'; rejectReason?: string } { + const d = debitGlCode.trim(); + const c = creditGlCode.trim(); + if (!isDoubleEntryPair(d, c)) { + return { valid: false, source: 'none', rejectReason: 'same_gl_account' }; + } const r0 = validateJournalPair(registry, debitGlCode, creditGlCode); if (r0.valid) { return { ...r0, source: 'registry' }; } - 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) { @@ -92,7 +96,7 @@ export function validateJournalPairWithMatrix( }; } } - return { valid: false, source: 'none' }; + return { valid: false, source: 'none', rejectReason: 'not_in_allowlist' }; } export function assertGlCodeKnown(registry: IpsasGlRegistry, glCode: string): boolean { diff --git a/services/token-aggregation/src/services/omnl-ipsas-journal-validation.test.ts b/services/token-aggregation/src/services/omnl-ipsas-journal-validation.test.ts new file mode 100644 index 0000000..d92382e --- /dev/null +++ b/services/token-aggregation/src/services/omnl-ipsas-journal-validation.test.ts @@ -0,0 +1,33 @@ +import { loadIpsasRegistry, validateJournalPair, validateJournalPairWithMatrix } from './omnl-ipsas-gl'; +import { loadJournalMatrix } from './omnl-journal-matrix'; + +describe('omnl IPSAS journal validation', () => { + const registry = loadIpsasRegistry(); + const matrix = loadJournalMatrix(); + + it('rejects same-GL pairs (invalid double entry)', () => { + const v = validateJournalPair(registry, '2000', '2000'); + expect(v.valid).toBe(false); + expect(v.rejectReason).toBe('same_gl_account'); + const vm = validateJournalPairWithMatrix(registry, matrix, '2000', '2000'); + expect(vm.valid).toBe(false); + expect(vm.rejectReason).toBe('same_gl_account'); + }); + + it('accepts Phase C Office 24 branch leg', () => { + const v = validateJournalPairWithMatrix(registry, matrix, '1410', '2100'); + expect(v.valid).toBe(true); + expect(v.memo).toMatch(/H24|INTEROFFICE/); + }); + + it('accepts Phase C HO interoffice leg 2100→2410', () => { + const v = validateJournalPairWithMatrix(registry, matrix, '2100', '2410'); + expect(v.valid).toBe(true); + }); + + it('accepts M2→M3 token load pair', () => { + const v = validateJournalPairWithMatrix(registry, matrix, '2200', '2300'); + expect(v.valid).toBe(true); + expect(v.memo).toBe('T-M2M3'); + }); +});