MOD-140 — Chart of accounts and GL configuration¶
System: SD01 Core Banking · Repo: bank-core · Phase: 5 Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-140-chart-of-accounts-gl-config/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048, ADR-053
1. Purpose¶
Provides the governed back-office interface for defining, maintaining, and amending the bank's chart of accounts and GL account-code configuration. Every posting in MOD-001 validates against codes defined here. Enforces four-eyes (maker/checker) approval at the DB level (GOV-007 GATE), propagates activated changes to MOD-080 statutory reporting + downstream consumers within 60s of activation (FR-623 / REP-002 AUTO), validates regulatory mappings against RBNZ/APRA line-item taxonomies before activation (FR-624 / REP-004 GATE), and ships with a default chart pre-mapped to RBNZ BS2A + APRA ARS 720 (~25 system accounts) that satisfies regulatory reporting without customisation.
2. Architecture¶
HTTP caller ── /internal/v1/gl/* ──> API Gateway ──> 8 HTTP Lambdas
│
├─ propose-create
├─ propose-modify ──┐
├─ propose-deactivate ──┤ proposal lifecycle
├─ review-approve ──┤ (writes to gl_account_proposals
├─ review-reject ──┘ + gl_governance_events)
├─ list-proposals
├─ get-account
└─ list-accounts
EventBridge cron(0 13 * * ? *) UTC = 01:00 NZDT / 02:00 NZST
│
▼
apply-effective-proposals (FR-623 daily activator)
├─ scan core.gl_account_proposals
│ WHERE status='approved' AND effective_date <= today
├─ for each: insert/update/deactivate core.gl_accounts
│ + flip proposal status='live'
│ + write GL_ACCOUNT_{ACTIVATED,DEACTIVATED,MODIFIED} governance row
└─ post-commit: emit bank.core.gl_account_* event per applied proposal
Each handler runs in a single Postgres transaction:
core.gl_accounts (chart master)
core.gl_account_proposals (maker/checker workflow rows)
core.gl_governance_events (per-module append-only audit log)
core.gl_regulatory_taxonomy (RBNZ/APRA line-item reference)
EventBridge bank-core (best effort, post-commit):
bank.core.gl_account_activated v1
bank.core.gl_account_deactivated v1
bank.core.gl_account_modified v1
EventBridge bank-platform (MOD-047 producer contract):
agent.action_taken / staff.action_taken — TODO: deferred until cross-domain
audit harness lands. v1 writes to per-module gl_governance_events only.
Module type¶
Application Lambda module (Postgres-backed + API Gateway). 9 Lambdas: 8 HTTP + 1 EventBridge-scheduled.
State transition pattern¶
Proposal lifecycle is pending → approved → live (or
pending → rejected / pending → withdrawn). The live transition
is performed by the daily scheduled Lambda — not by the review-approve
handler — so the effective_date semantics are honoured (approved
proposals dated in the future stay approved until their date arrives).
3. Data model¶
core.gl_accounts (V001) — chart master¶
| Column | Notes |
|---|---|
account_code |
TEXT PK; alphanumeric uppercase + dashes, 1–16 chars |
account_name |
NOT NULL |
account_type |
NOT NULL ∈ |
account_subtype |
free-text |
currency |
NOT NULL char(3) ∈ |
is_system_account |
NOT NULL; true for V005 default chart, immutable to back-office |
regulatory_mappings |
jsonb array of {jurisdiction, return_code, line_item_code} |
status |
NOT NULL ∈ |
parent_account_code |
self-FK for sub-account hierarchy |
activated_at, deactivated_at |
ADR-048 V006 CHECK: deactivated_at > activated_at |
Hot-path index: idx_gl_accounts_active_codes WHERE status='active' —
keeps the MOD-001 posting validator's lookup tight as the chart grows.
core.gl_account_proposals (V002) — maker/checker workflow¶
| Column | Notes |
|---|---|
proposal_id |
uuid PK (uuid v7) |
account_code, account_name, account_type, account_subtype, currency, regulatory_mappings, parent_account_code, description |
proposed account state |
change_type |
NOT NULL ∈ |
change_reason |
NOT NULL — free-text rationale |
proposed_by |
uuid; cross-domain staff UUID, no FK (orchestrator Q4) |
proposed_at |
timestamptz |
status |
NOT NULL ∈ |
reviewed_by |
uuid; CHECK no_self_approval enforces <> proposed_by (GOV-007) |
reviewed_at, review_comment |
populated together with reviewed_by |
effective_date |
NOT NULL; ADR-048 V006 CHECK: >= proposed_at::date |
applied_at |
timestamptz; populated iff status='live' |
before_state |
jsonb snapshot for modify/deactivate proposals |
idempotency_key |
UNIQUE NOT NULL |
Hot-path index: idx_gl_account_proposals_approved_pending_effective WHERE status='approved'
— keeps the daily applier's scan tight.
core.gl_governance_events (V003) — append-only audit log¶
7-value event_type CHECK: PROPOSAL_CREATED, PROPOSAL_APPROVED,
PROPOSAL_REJECTED, PROPOSAL_WITHDRAWN, GL_ACCOUNT_ACTIVATED,
GL_ACCOUNT_DEACTIVATED, GL_ACCOUNT_MODIFIED.
idempotency_key UNIQUE NOT NULL per ADR-048. V006 immutability triggers
reject UPDATE/DELETE/TRUNCATE. bank_core_app_user has only INSERT +
SELECT.
core.gl_regulatory_taxonomy (V004) — RBNZ/APRA line-item reference¶
| Column | Notes |
|---|---|
jurisdiction |
char(2) ∈ |
return_code |
TEXT — e.g. 'BS2A', 'BS6', 'ARS720', 'ARS740' |
line_item_code |
TEXT — regulator's identifier within the return |
line_item_name, line_item_description |
human-readable |
status |
NOT NULL ∈ |
effective_from, effective_to |
regulatory effective dates |
| UNIQUE | (jurisdiction, return_code, line_item_code) |
V004 seed covers the line items needed to validate the V005 default chart (RBNZ BS2A + BS6 + APRA ARS 720 + 740 — ~28 rows). Future regulator amendments ship as discrete versioned migrations (V007__rbnz_bs2a_2027_amendment.sql etc., per orchestrator Q2 — NOT repeatable migrations; the audit trail must be immutable).
V005 default chart — ~25 accounts, all is_system_account=true¶
| Code | Name | Type | RBNZ mapping | APRA mapping |
|---|---|---|---|---|
| 1000 | Cash and cash equivalents | asset | BS2A.cash | ARS720.cash |
| 1010 | Due from other banks | asset | BS2A.due-from-banks | — |
| 1100 | Loans — residential mortgages | asset | BS2A.loans-residential | ARS720.loans-housing |
| 1110 | Loans — commercial | asset | BS2A.loans-commercial | ARS720.loans-commercial |
| 1120 | Loans — personal | asset | BS2A.loans-personal | — |
| 2000 | Customer transaction deposits | liability | BS2A.transaction-deposits | ARS720.transaction-deposits |
| 2010 | Customer term deposits | liability | BS2A.term-deposits | ARS720.term-deposits |
| 2100 | Wholesale funding | liability | BS2A.wholesale-funding | — |
| 2200 | Accrued interest payable | liability | — | — |
| 2900 | Suspense — operations | liability | — | — |
| 3000 | Share capital | equity | BS2A.share-capital | — |
| 3100 | Retained earnings | equity | BS2A.retained-earnings | — |
| 4000 | Interest income — lending | income | BS6.interest-income-lending | ARS740.interest-income-lending |
| 4010 | Interest income — investments | income | — | — |
| 4100 | Fee income — transaction | income | BS6.fee-income | ARS740.fee-income |
| 4110 | Fee income — account servicing | income | BS6.fee-income | — |
| 4200 | Other operating income | income | BS6.other-operating-income | — |
| 4900 | Recoveries | income | — | — |
| 5000 | Interest expense — deposits | expense | BS6.interest-expense-deposits | ARS740.interest-expense-deposits |
| 5010 | Interest expense — wholesale | expense | BS6.interest-expense-wholesale | — |
| 5100 | Credit impairment charge | expense | BS6.impairment-charge | — |
| 5200 | Staff costs | expense | BS6.staff-costs | ARS740.staff-costs |
| 5300 | Technology and premises costs | expense | BS6.technology-costs | — |
| 5400 | Regulatory levies | expense | — | — |
1000 / 2000 are MOD-001's canonical debit/credit pair — every
existing MOD-001 test posts 1000 → 2000 (deposit in) or 2000 → 1000
(withdraw out). The default chart preserves these codes so MOD-001's
existing tests + dev seeds keep working unchanged (orchestrator Q3
sign-off).
4. ADR-048 DB-enforced invariants register¶
| Table | Enforcement | Migration | Negative test |
|---|---|---|---|
core.gl_governance_events |
Immutability triggers (UPDATE/DELETE/TRUNCATE) | V006 | tests/integration/db-trigger-gl-governance-immutable.test.ts |
core.gl_governance_events |
idempotency_key UNIQUE NOT NULL |
V003 | tests/integration/db-unique-governance-idempotency-key.test.ts |
core.gl_account_proposals |
no_self_approval CHECK (reviewed_by != proposed_by) |
V002 | tests/integration/db-check-no-self-approval.test.ts |
core.gl_account_proposals |
chk_gl_proposal_effective_after_proposed CHECK |
V006 | tests/integration/db-check-gl-temporal.test.ts |
core.gl_accounts |
chk_gl_account_deactivated_after_activated CHECK |
V006 | tests/integration/db-check-gl-temporal.test.ts |
core.gl_accounts |
parent_account_code REFERENCES gl_accounts(account_code) self-FK |
V001 | covered indirectly by proposal validator + V005 seed |
5. Maker/checker workflow¶
propose-create propose-modify propose-deactivate
│ shape validation │ shape validation │
│ taxonomy validation │ taxonomy validation │ system_account check
│ idempotent replay │ system_account check │ already-inactive check
│ │ before_state snapshot │ before_state snapshot
▼ ▼ ▼
INSERT gl_account_proposals (status='pending', reviewed_by=NULL)
INSERT gl_governance_events (PROPOSAL_CREATED)
┌───────────────────────────────────────────────────────┐
│ reviewer (different staff) calls one of: │
▼ ▼
review-approve review-reject
│ four-eyes check (app) │ four-eyes check
│ taxonomy re-validation (REP-004 GATE) │
▼ UPDATE proposal (status='approved') ▼ UPDATE proposal (status='rejected')
INSERT gl_governance_events (PROPOSAL_APPROVED) INSERT gl_governance_events (PROPOSAL_REJECTED)
EventBridge cron 01:00 NZDT (cron(0 13 * * ? *) UTC):
apply-effective-proposals scans approved proposals with
effective_date <= today, applies each in its own tx:
INSERT/UPDATE/UPDATE gl_accounts row
UPDATE proposal status='live', applied_at=now()
INSERT gl_governance_events (GL_ACCOUNT_*)
then post-commit emits bank.core.gl_account_* events.
6. Regulatory mapping validation (FR-624 / REP-004 GATE)¶
regulatory-mapping-pure.ts does the work:
validateMappings(proposed: RegulatoryMapping[], taxonomy: TaxonomyEntry[])
→ { ok: true }
| { ok: false, retired: RegulatoryMapping[], unknown: RegulatoryMapping[] }
Each proposed entry is looked up by (jurisdiction, return_code,
line_item_code):
- Active hit → ok
- Retired hit → blocks approval; user-facing remediation is "update to the renamed line item code"
- Unknown → blocks approval; user-facing remediation is "fix typo / out-of-date documentation"
Validation runs at TWO points: 1. At proposal-create time — surfaces issues immediately so the proposer can iterate without waiting for a reviewer. 2. At review-approve time — re-validated against the live taxonomy so a reviewer cannot approve into an inconsistent state if the taxonomy moved between submission and review.
A proposal with retired/unknown mappings is left in status='pending'
(NOT auto-flipped to rejected) so the proposer can submit a follow-up
modify-proposal to fix the mapping. Only an explicit review-reject
turns it into rejected.
7. Effective-date scheduler (FR-623)¶
# infra/index.ts
scheduleExpression: cron(0 13 * * ? *) # 13:00 UTC
# = 01:00 NZDT (UTC+13, ~Sep–Apr)
# = 02:00 NZST (UTC+12, ~Apr–Sep)
Runs the apply-effective-proposals Lambda once per day. Per
orchestrator Q5: 01:00–02:00 local time is acceptable for a GL
activation job; the runbook documents the DST shift.
The Lambda is idempotent: a second run on the same day finds no
matching status='approved' rows for proposals already flipped to
live, so re-runs are no-ops. Manual intervention path: a human
operator can invoke the Lambda at any time via aws lambda invoke to
process approved proposals before their next scheduled run.
8. SSM outputs¶
| Path | Value |
|---|---|
/bank/{stage}/mod-140/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-140/lambdas/{propose-create,propose-modify,propose-deactivate,review-approve,review-reject,list-proposals,get-account,list-accounts,apply-effective-proposals}/arn |
per-handler ARN |
/bank/{stage}/mod-140/tables/{gl-accounts,gl-account-proposals,gl-governance-events,gl-regulatory-taxonomy}/name |
table FQNs |
9. Cross-module touches¶
None. MOD-140 introduces 4 new tables under the existing core
schema, 3 new EventBridge events on the existing bank-core bus, and
9 new Lambdas. No ALTERs on existing tables, no new schemas, no
changes to MOD-001 / MOD-007 / MOD-047 source code.
MOD-001 will eventually consume MOD-140's core.gl_accounts via the
already-deployed isActivePostableCode helper from this module's
gl-store.ts. The wiring is one-direction: MOD-001 reads, MOD-140
writes. No circular dependency.
10. Policies satisfied¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| REP-004 | GATE | review-approve re-validates regulatory_mappings against the live taxonomy and throws REGULATORY_MAPPING_INVALID on retired/unknown line items. |
tests/policy/rep-004-gate-regulatory-mapping-approval.test.ts |
| GOV-006 | LOG | core.gl_governance_events is append-only via V006 immutability triggers + privilege revocation; idempotency_key UNIQUE NOT NULL lets writers retry safely without duplicates. |
tests/policy/gov-006-log-immutable-governance.test.ts |
| REP-002 | AUTO | apply-effective-proposals writes a GL_ACCOUNT_ACTIVATED governance row + emits bank.core.gl_account_activated v1 within the same Lambda invocation; downstream MOD-080 / MOD-082 will subscribe when they ship. |
tests/policy/rep-002-auto-publish-on-activation.test.ts |
| GOV-007 | GATE | Three-layer four-eyes: (1) app-layer reviewApprove throws FOUR_EYES_VIOLATION when reviewer = proposer; (2) DB CHECK no_self_approval rejects the UPDATE; (3) UI guidance (out of scope for backend). |
tests/policy/gov-007-gate-four-eyes.test.ts + tests/integration/db-check-no-self-approval.test.ts |
11. Test approach + results (locally — unit/contract only)¶
| Tier | Files | Result |
|---|---|---|
| Unit | tests/unit/{errors, logger, emf, proposal-validator-pure, regulatory-mapping-pure}.test.ts |
31 / 31 |
| Contract | tests/contract/gl-events.test.ts |
7 / 7 |
| FR integration | tests/integration/fr-{621,622,623,624}-*.test.ts |
(run in CI) |
| ADR-048 negative | tests/integration/db-{trigger,check,unique}-*.test.ts |
(run in CI) |
| Policy | tests/policy/{rep-004, gov-006, rep-002, gov-007}-*.test.ts |
(run in CI) |
Local: 38 / 38 unit + contract tests passing. Integration + policy require RUN_INTEGRATION=1 + dev Neon — exercised in CI.
12. Architectural decisions captured here¶
-
Two-table proposal pattern (orchestrator Q3 / Q4 / Q6) —
gl_accountsis the active master;gl_account_proposalsis the pending workflow. Modify/deactivate proposals snapshotbefore_stateso the audit log carries full delta without temporal queries on a non-temporal master. -
Default chart pre-seeded as
is_system_account=true(orchestrator Q3) — V005 ships ~25 accounts pre-mapped to RBNZ BS2A + APRA ARS 720 / BS6 / ARS 740. System accounts are immutable to the back-office proposal workflow (FR-622); only platform deployments can change them. Includes1000(Cash) +2000(Customer transaction deposits) as superset of MOD-001's existing canonical codes. -
Versioned migrations for taxonomy updates (orchestrator Q2) — each RBNZ/APRA amendment ships as a discrete
V00N__rbnz_or_apra_amendment.sqlmigration. NOT repeatable migrations; the audit trail must be immutable per ADR-048 principles. -
Cross-domain UUIDs for staff identity (orchestrator Q4) —
proposed_byandreviewed_byare uuid columns with NO FK. Identity ownership lives in the future staff-domain modules (out of bank-core). The DB CHECKno_self_approvalenforces non-equality structurally; cross-domain ownership concerns are app-layer. -
Activation via daily scheduled Lambda, not synchronous on approval (orchestrator Q5) —
effective_datesemantics are honoured: a proposal approved today with effective_date = next month staysapproveduntil that month, then flips toliveon the daily applier's pass. Croncron(0 13 * * ? *)UTC = 01:00 NZDT / 02:00 NZST. -
MOD-080 / MOD-082 events published into the void (orchestrator Q1) — events are defined by what MOD-140 does, not by who's listening. v1 emits
bank.core.gl_account_activatedetc.; future MOD-080 statutory reporting + MOD-082 management reporting will subscribe when they ship. EventBridge schema registry pin lands as part of the wiki update.
13. Named debt items¶
-
MOD-080 statutory reporting — Not started. Will subscribe to
bank.core.gl_account_activatedv1 to keep its return-template references current within the FR-623 60s budget. -
MOD-082 management reporting — Not started. Same subscription as MOD-080.
-
MOD-127 product configuration panel — Not started. Will validate product-level GL code references against the chart via
GET /internal/v1/gl/accounts/{account_code}(or a dedicated bulk validation endpoint added when MOD-127 lands). -
MOD-001 posting validator wiring — MOD-001's
isActivePostableCodecall site doesn't exist yet (MOD-001 was deployed before MOD-140). Wiring is a one-line addition to MOD-001's posting-service.ts; ships as a MOD-001 follow-up commit when this audit cycle stabilises. -
MOD-047 cross-domain governance bus —
agent.action_taken/staff.action_takenevents to the bank-platform bus are NOT published in v1. v1 writes to per-modulecore.gl_governance_eventsonly; the cross-domain audit harness lands when MOD-047's consumer pattern stabilises across all SD01 modules. -
Generalising the per-module governance log — MOD-125, MOD-133, MOD-134, and now MOD-140 each have parallel
core.{domain}_governance_eventstables with similar shape. If a fifth use case appears, promote to a sharedcore.governance_eventsabstraction. -
Bulk validation endpoint —
validateMappingsis exposed only internally (called fromproposeCreate/reviewApprove). When MOD-127 lands, it'll need aPOST /internal/v1/gl/validate-mappingsendpoint to bulk-check codes before submitting product configurations. Trivial wrapper around the existing pure helper.