Skip to content

Technical design — MOD-001 Double-entry posting engine

Module: MOD-001 System: SD01 Core Banking Repo: bank-core FR scope: FR-045, FR-046, FR-047, FR-048, FR-421, FR-422, FR-423, FR-424 NFR scope: NFR-012, NFR-013, NFR-019, NFR-024 Policies satisfied: CLQ-006 (AUTO), REP-004 (AUTO), PAY-001 (GATE), CLQ-002 (CALC), PAY-007 (LOG), OPS-007 (LOG) Author: AI coding agent (Claude) Date: 2026-04-22

Objective

MOD-001 is the authoritative double-entry posting engine for the bank. Every movement of money in or out of a customer account is a pair of postings committed atomically to accounts.postings by this module, together with an atomic update of the denormalised balance on accounts.accounts. It is the single source of financial truth for SD01 and the substrate for every downstream ledger consumer (real-time balance engine, immutable transaction log, interest accrual, FX conversion, dormancy, loan servicing, CDC pipeline).

Internal architecture

 API GW ─▶ post-posting.handler
          posting-service.processPosting
              │   (withTransaction — single Postgres BEGIN/COMMIT)
              ├─▶ idempotency-service.lookup      — FR-048 replay
              ├─▶ account-repository.lockAccount  — FOR UPDATE on both legs
              ├─▶ [validators]                    — FR-045 / 046 / 422 / 424
              ├─▶ INSERT accounts.postings (×2)
              ├─▶ account-repository.applyBalanceDelta (×2)
              └─▶ idempotency-service.record
              │ COMMIT
          event-publisher.publishPostingCompleted
              — bank.core.posting_completed → bank-core bus
              — 3-attempt exponential backoff per error-handling standard

Everything inside processPosting runs inside a single Postgres transaction; the EventBridge publish is explicitly the last step, per §Partial failure in the error handling standard (publish-last, retry-then-reconstruct on failure).

Key design decisions

Decision: time-ordered IDs via gen_uuidv7()

Context: FR-047 requires a monotonically increasing posting ID for deterministic replay. The SD01 schema declares id uuid on accounts.postings.

Choice: Use PG17's built-in gen_uuidv7() as the column default rather than gen_random_uuid().

Reason: UUIDv7 carries a 48-bit unix-ms timestamp in its first 12 hex digits; consecutive ids are lexicographically ordered by insertion time, satisfying the monotonic-replay requirement without a separate sequence column. No extension is required on PG17+.

Trade-offs: PG17 is required. Ordering is per-instance, not globally distributed — for a multi-writer replay we also have posting_date and created_at for correlation. See handoff note on gen_uuidv7() naming.

Decision: two-layer immutability (revoke + trigger)

Context: PAY-007 LOG and OPS-007 LOG require that accounts.postings admits zero modifications.

Choice: V001 revokes UPDATE/DELETE from bank_core_app_user. V003 adds a trigger function accounts.postings_reject_mutation that raises on BEFORE UPDATE / DELETE / TRUNCATE for every row.

Reason: Revocation is the first layer but a superuser action or a migration user running DML bypasses it. The trigger closes that hole — even the bank_core_migrate_user role cannot mutate rows.

Trade-offs: Genuine schema evolution that needs to rewrite rows must drop the trigger, do the work, and reinstate. That hoop is intentional.

Decision: ADR-048 DB-enforced invariants (V007)

Context: ADR-048 promotes Postgres constraints and triggers to a first-class defence layer alongside Lambda validation. The SD01 register lists three triggers on accounts.postings plus a temporal CHECK on accounts.account_products.

Choice (V007):

  • trg_posting_account_status_dormant — BEFORE INSERT trigger rejecting any posting (DEBIT or CREDIT) against an account whose status is DORMANT. Defence in depth alongside MOD-007 V005's trg_postings_block_on_restricted_or_closed (which covers RESTRICTED-DEBIT + CLOSED) and MOD-001's Lambda check in posting-service.ts loadAccountForPosting.
  • trg_postings_double_entry — AFTER INSERT, DEFERRABLE INITIALLY DEFERRED constraint trigger checking SUM(DEBIT amount) = SUM(CREDIT amount) per (transaction_id, currency) at COMMIT time. The deferred timing is load-bearing: both legs of a 2-leg single-currency transaction — and all four legs of a MOD-004 cross-currency conversion — must be visible when the check fires. Per-currency grouping is what makes 4-leg multi-currency conversions structurally correct: each currency balances independently.
  • chk_account_products_effective_to — temporal coherence CHECK on accounts.account_products: effective_to IS NULL OR effective_to > effective_from. Mirrors the pattern that core.fee_schedule and accounts.interest_rates follow.

Reason: Lambda enforcement is per-call. A migration script, a hot-fix psql session, or a future module that writes to the same table also needs to be safe — the DB constraint is the floor that every path crosses. Lambda continues to produce the user-facing 422 error envelopes (UNBALANCED_LEGS, ACCOUNT_NOT_ACTIVE, etc.); the DB trigger is the structural backstop.

Trade-offs: The deferred constraint trigger is invisible to INSERT-time error handling — failure surfaces only at COMMIT, and a caller that ignores the COMMIT error will think the writes "succeeded". Mitigated by the existing Lambda UNBALANCED_LEGS check, which never lets a known-unbalanced posting reach the DB. The new test db-trigger-double-entry-deferred.test.ts proves the COMMIT path fires for direct-SQL bypass attempts.

On the existing immutability triggers (V003): Already provide ADR-048's "immutability" requirement on accounts.postings. Named trg_postings_no_{update,delete,truncate} against accounts.postings_reject_mutation() rather than ADR-048's reference trg_postings_immutable / fn_immutable_row() — same behaviour, deployed first, with PAY-007 negative tests already green. Not renamed in V007.

Decision: credit leg is the event's primary account_id

Context: The v1.0.0 event catalogue schema for bank.core.posting_completed carries one account_id plus optional counterparty_account_id. The FR-421 prose asked for "both account IDs".

Choice: Emit the event with account_id = credit leg, direction = CREDIT, and counterparty_account_id = debit leg. One event per transaction.

Reason: The consumer view of a posting is "money arrived at this account" for a credit, which matches the downstream use cases (MOD-022 payment audit, MOD-042 CDC, MOD-002 immutable log, notification orchestration). The catalogue is binding per the spec resolution.

Decision: available_balance factors pending_holds dynamically

Context: FR-046 rejects postings that would drive available_balance negative unless overdraft is explicitly authorised. accounts.accounts has a denormalised available_balance column but accounts.pending_holds also reduces spendable.

Choice: On insufficient-funds check, compute spendable = available_balance - Σ active holds + overdraft_limit and reject if spendable < debit_amount.

Reason: Holds are written by MOD-020 and MOD-023 (pre-payment validation, fraud scoring) but not all hold releases flow through MOD-001. Recomputing on read is O(1) because of idx_pending_holds_account_id_active and is correct under concurrent hold creation.

External dependencies

  • Database: bank_core on Neon (provisioned by MOD-103).
  • READ/INSERT: accounts.postings
  • READ/UPDATE: accounts.accounts
  • READ: accounts.pending_holds
  • READ/INSERT: accounts.idempotency_keys
  • SCHEMA-OWNER (no runtime access): accounts.account_products, accounts.account_party_relationships — see "SD01 platform schema bootstrap" note below.
  • EventBridge: emits bank.core.posting_completed on bank-core bus.
  • Secrets Manager: bank-neon/{stage}/bank_core/app_user — Neon connection for the bank_core_app_user role.
  • SSM:
  • READ /bank/{stage}/eventbridge/bank-core/arn
  • READ /bank/{stage}/iam/lambda/bank-core/arn
  • READ /bank/{stage}/observability/adot-layer-arn
  • WRITE /bank/{stage}/mod-001/api/base-url
  • WRITE /bank/{stage}/mod-001/lambda/arn
  • WRITE /bank/{stage}/mod-001/idempotency-table
  • WRITE /bank/{stage}/mod-001/error-codes

SD01 platform schema bootstrap (V004 + V005)

MOD-001 is the first SD01 module deployed and currently the only owner of accounts.* Flyway. The runtime owners of two further SD01 tables — MOD-006 (rate change propagation) for accounts.account_products, and MOD-007 / MOD-012 for accounts.account_party_relationships — haven't shipped yet. To unblock cross-domain consumers (notably MOD-158 test seed loader, which writes seed customers + accounts + relationships on fresh dev / uat) MOD-001 ships their Flyway as V004 and V005:

  • V004__account_products.sql — schema per SD01 design + role grants + 4 currently-active seed rows for the (NZ, TRANSACTION), (NZ, SAVINGS), (AU, TRANSACTION), (AU, SAVINGS) combos. Rates are realistic baselines for dev; production rates land via MOD-006 once it ships.
  • V005__account_party_relationships.sql — schema per SD01 design + role grants. No seed data — rows are written by MOD-012 on customer onboarding and by MOD-158 for test seeds. The party_id cross-domain reference is app-layer enforced (no Postgres FK) per the SD01 design standards.

When MOD-006 / MOD-007 / MOD-012 ship, they own behaviour on these tables; the schema migration responsibility transfers via the standard "runtime owner takes the migration on next bump" pattern. Their initial Flyway migrations should be no-ops (everything already exists) or pure ALTER TABLE additions.

SSM outputs table

Output SSM path Consumers
Internal posting API base URL /bank/{stage}/mod-001/api/base-url MOD-020, MOD-025, MOD-022, MOD-005, MOD-031, MOD-065
Posting Lambda ARN /bank/{stage}/mod-001/lambda/arn MOD-020 (direct invoke if selected)
Idempotency table name /bank/{stage}/mod-001/idempotency-table MOD-002 (audit reconciliation)
Posting error-code enumeration /bank/{stage}/mod-001/error-codes MOD-020 for PAYMENT_FAILED mapping

Security and data handling

  • No customer PII touched directly — the module processes account_id (UUID) and monetary amounts only.
  • Connections to Neon use TLS with certificate verification.
  • Secrets resolved via Secrets Manager at cold start; never in code or env vars.
  • Logs emit party_id = null (no party context on posting requests); money amounts appear only in the structured audit fields, never as free text, satisfying the observability standard's PII-free rule.

Performance approach

  • NFR-012 target is p99 ≤ 10 ms posting write latency. The critical path is BEGIN → two FOR UPDATE locks → two INSERTs → two UPDATEs → idempotency INSERT → COMMIT. All indexes needed are in V001; the row locks are taken on primary key, which is unique and indexed.
  • Reserved concurrency 100 per the SD04/SD01 bulkhead baseline.
  • ADOT layer attached for X-Ray service map visibility (sampling 5% per MOD-104 default rule).
  • End-to-end p99 verification is a staging load test owned by the SD01 tech lead — the integration-test NFR-012 check here bounds dev single-posting latency at 500 ms as a regression gate.

Error handling

Per the error handling standard:

  • Validation and balance/jurisdiction gates raise ValidationFailure or ComplianceBlock → HTTP 422 with stable error_code from the FR-423 enumeration.
  • DB connection / EventBridge failures raise TransientInfra → HTTP 503 with retryable: true; caller retries with the same idempotency_key.
  • Unclassified exceptions → HTTP 500 INTERNAL_ERROR, never retried.
  • EventBridge publish is the last step; on publish failure we retry up to 3 times with exponential backoff. If all retries fail, the commit has already happened — consumers that depend on the event will be behind until a replay tool reconstructs the event from the committed accounts.postings row. This is flagged in the handoff as a known follow-up for MOD-002 / MOD-043.

Event types emitted in structured logs

Registered in src/lib/logger.ts (EVENT_TYPES):

  • posting_committed — successful commit
  • posting_rejected — classified error path
  • idempotent_replay — idempotency cache hit
  • trace_id_missing_from_upstream — missing trace header (WARN)
  • event_publish_failed / event_publish_retry — EventBridge publish
  • validation_failed / internal_error

Test approach

Test type Location Count
Unit tests/unit/ amount, logger, trace, errors, emf (5 suites)
Contract tests/contract/ posting-request/response, posting_completed event (2 suites)
FR integration tests/integration/fr-* one per FR (FR-045..048, FR-421..424) + NFR-012 + observability = 11
Policy satisfaction tests/policy/ six — one per row in policies_satisfied

Integration and policy suites hit the deployed dev Neon branch; they skip cleanly when NEON_DIRECT_HOST/NEON_APP_PASSWORD are not in the environment. Run from CI under the bank-platform-cicd OIDC role or locally with AWS_PROFILE=bank-dev STAGE=dev pnpm test:integration.

Coverage layering

pnpm test:unit --coverage is the reusable-lambda workflow's 80% gate, run separately from the integration suite. The unit-coverage scope in vitest.config.ts is therefore restricted to the pure layers — code whose correctness is expressible without AWS/Postgres. Layers that require real infra are covered by the integration and policy runs against deployed dev:

Layer Unit-coverage scope Verified by
lib/amount.ts, lib/errors.ts, lib/logger.ts, lib/trace.ts, lib/emf.ts ✓ (≥80% gate) unit tests
lib/db.ts, lib/bus-arn.ts excluded integration (every FR test)
services/* excluded integration + policy suites
handlers/post-posting.ts excluded observability + FR-423 integration suites
config.ts excluded integration (every handler invocation)
infra/* n/a (SST IaC) SST deploy dry-run in CI