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 isDORMANT. Defence in depth alongside MOD-007 V005'strg_postings_block_on_restricted_or_closed(which covers RESTRICTED-DEBIT + CLOSED) and MOD-001's Lambda check inposting-service.ts loadAccountForPosting.trg_postings_double_entry— AFTER INSERT,DEFERRABLE INITIALLY DEFERREDconstraint trigger checkingSUM(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 onaccounts.account_products:effective_to IS NULL OR effective_to > effective_from. Mirrors the pattern thatcore.fee_scheduleandaccounts.interest_ratesfollow.
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_coreon 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_completedonbank-corebus. - Secrets Manager:
bank-neon/{stage}/bank_core/app_user— Neon connection for thebank_core_app_userrole. - 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. Theparty_idcross-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
ValidationFailureorComplianceBlock→ HTTP 422 with stableerror_codefrom the FR-423 enumeration. - DB connection / EventBridge failures raise
TransientInfra→ HTTP 503 withretryable: true; caller retries with the sameidempotency_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.postingsrow. 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 commitposting_rejected— classified error pathidempotent_replay— idempotency cache hittrace_id_missing_from_upstream— missing trace header (WARN)event_publish_failed/event_publish_retry— EventBridge publishvalidation_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 |