Skip to content

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_accounts is the active master; gl_account_proposals is the pending workflow. Modify/deactivate proposals snapshot before_state so 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. Includes 1000 (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.sql migration. NOT repeatable migrations; the audit trail must be immutable per ADR-048 principles.

  • Cross-domain UUIDs for staff identity (orchestrator Q4) — proposed_by and reviewed_by are uuid columns with NO FK. Identity ownership lives in the future staff-domain modules (out of bank-core). The DB CHECK no_self_approval enforces non-equality structurally; cross-domain ownership concerns are app-layer.

  • Activation via daily scheduled Lambda, not synchronous on approval (orchestrator Q5) — effective_date semantics are honoured: a proposal approved today with effective_date = next month stays approved until that month, then flips to live on the daily applier's pass. Cron cron(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_activated etc.; 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_activated v1 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 isActivePostableCode call 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 busagent.action_taken / staff.action_taken events to the bank-platform bus are NOT published in v1. v1 writes to per-module core.gl_governance_events only; 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_events tables with similar shape. If a fifth use case appears, promote to a shared core.governance_events abstraction.

  • Bulk validation endpointvalidateMappings is exposed only internally (called from proposeCreate / reviewApprove). When MOD-127 lands, it'll need a POST /internal/v1/gl/validate-mappings endpoint to bulk-check codes before submitting product configurations. Trivial wrapper around the existing pure helper.