Skip to content

MOD-110 — Fee engine

System: SD01 Core Banking · Repo: bank-core Phase: 5 · Build status: Built (pre-deploy) Depends on: MOD-001, MOD-003, MOD-104, MOD-103 Date: 2026-05-01

Purpose

Single point of truth for fee logic across SD01. Every fee — assessment, waiver evaluation, posting, reversal — flows through MOD-110. Owns two new tables (core.fee_schedule, core.fee_events) and writes to accounts.postings directly under the declared ledger-direct-write contract (sibling to MOD-004's FX conversion). The fee posting + the core.fee_events audit row commit atomically in a single Postgres transaction.

Architecture

                 caller (payments / statements / future cron)
                  POST /internal/v1/fees/assess
                  Mod110AssessHandler
              ┌── pure: schedule-resolver-pure ────┐
              │  pure: waiver-evaluator-pure       │
              └── DB:   schedule-store / loader ───┘
                  fee-poster.ts (single Postgres tx)
                ┌─── INSERT accounts.postings × 2 (DEBIT customer + CREDIT income)
                ├─── UPDATE accounts.accounts × 2 (balance, available_balance, version)
                └─── INSERT core.fee_events (lifecycle WAIVED or POSTED)
                  bank.core.fee_assessed
                  bank.core.fee_posted (when posted)
                  consumers: MOD-002, MOD-042, MOD-043

Reversal mirrors the same shape: a compensating CREDIT/DEBIT pair + new core.fee_events row with lifecycle=REVERSED, all atomically.

State + data model

core.fee_schedule (V001) — tenant-configurable rates

Column Notes
schedule_id uuid PK
tenant_id NOT NULL — defaults to totara-bank (orchestrator A1)
product_id matches accounts.accounts.product_code literally (orchestrator A2)
fee_type e.g. MONTHLY_ACCOUNT_FEE, DISHONOUR_FEE
amount, currency numeric(18,2) + ISO 4217; CHECK enforces NZ_/AU_ product prefix matches NZD/AUD currency
waiver_conditions jsonb array of typed condition objects (5 types)
notice_days int, CHECK ≥ 14 (CON-005 retail floor; ADR-048 register entry)
proposed_effective_from what admin requested
effective_from computed at publication: MAX(proposed, published_at + notice_days) for increases; equal to proposed for reductions/same-rate
effective_to nullable; CHECK effective_to IS NULL OR effective_to > effective_from (V006, ADR-048 temporal coherence); mutable on supersession
version int; UNIQUE on (tenant_id, product_id, fee_type, version)

core.fee_events (V002) — append-only audit log

lifecycle ∈ {WAIVED, POSTED, REVERSED}. Coherence CHECK ensures the right fields are populated for each: - WAIVEDposted_amount IS NULL, posting_id IS NULL, waived = true, waiver_reason set - POSTEDposted_amount IS NOT NULL, posting_id IS NOT NULL, waived = false - REVERSEDreverses_event_id, reversal_posting_id, reversal_reason, authorised_by all NOT NULL

Idempotency: UNIQUE (idempotency_key) — same call twice returns the same row with HTTP 200 (orchestrator A13).

V003 trigger blocks UPDATE/DELETE/TRUNCATE (BYPASS-RLS load-bearing).

Internal accounts (V004 seed)

product_code currency jurisdiction Purpose
INTERNAL_FEE_INCOME_NZ NZD NZ Credit destination for NZD fees
INTERNAL_FEE_INCOME_AU AUD AU Credit destination for AUD fees

Dev fee schedules (V005 seed)

3 demo rows for testing: - NZ_TRANSACTION_01 / MONTHLY_ACCOUNT_FEE / NZD 5.00 / waivers: zero_balance + recent_open(90) - NZ_TRANSACTION_01 / DISHONOUR_FEE / NZD 12.00 / no waivers - AU_TRANSACTION_01 / MONTHLY_ACCOUNT_FEE / AUD 5.00 / waivers: zero_balance + recent_open(90)

Declared ledger-direct-write contract satisfaction

Following MOD-004's precedent (the seven contract conditions captured in MOD-004's design doc):

Condition MOD-110 satisfaction
1. Atomicity required 2 postings + 2 account UPDATEs + 1 fee_events row in one Pg tx
2. Same writer role INSERT runs as bank_core_app_user; V005 / V003 triggers from MOD-007 / MOD-002 still gate
3. Schema parity all NOT NULL columns populated; id = gen_uuidv7() (well, randomUUID() — same shape, time-ordered enough); entry_type ∈ {DEBIT,CREDIT}; amount > 0; currency from core.fee_schedule.currency; jurisdiction ∈ {NZ,AU}; source_module = 'MOD-110'; narrative set; metadata carries {module_id, fee_type, schedule_id, schedule_version, customer_initiated: false, trace_id}
4. Balance update pessimistic SELECT ... FOR UPDATE on both leg accounts in the same transaction; signed deltas; version incremented
5. Eventing publishes bank.core.fee_assessed (always) + bank.core.fee_posted (on POSTED) + bank.core.fee_reversed (on REVERSED). MOD-002 already accepts these via the catalogue's listed consumers.
6. Idempotency caller-supplied idempotency_key on a UNIQUE index on core.fee_events. Replay returns the existing row.
7. Verification integration test fr-492-double-entry-atomic.test.ts round-trips an assessed posting and checks transaction_id linkage + balance update + metadata.customer_initiated flag

Application-level gates (orchestrator A14.a)

MOD-110 re-implements MOD-001's posting gates inline. Drift accepted for v1; lift to @bank-core/shared/posting-gates when MOD-005 (third writer) lands.

Gate How
Account exists + locked SELECT ... FOR UPDATE
status = 'ACTIVE' rejected with ACCOUNT_NOT_ACTIVE 422 (PENDING/RESTRICTED/DORMANT/CLOSED)
Currency match rejected with CURRENCY_MISMATCH 422
Jurisdiction match implicit via product_code prefix CHECK on schedule + posting jurisdiction match
Sufficient funds (DEBIT) rejected with INSUFFICIENT_FUNDS 422
RESTRICTED / CLOSED also blocked at DB level by MOD-007 V005 trigger (defence-in-depth)

Lambdas

Function Trigger Purpose
Mod110AssessHandler HTTP POST /internal/v1/fees/assess Evaluate + post-or-waive a fee
Mod110ReverseHandler HTTP POST /internal/v1/fees/{event_id}/reverse Compensating reversal
Mod110ListByAccountHandler HTTP GET /internal/v1/fees/by-account/{account_id} List recent fee events

No cron in v1 (orchestrator A12 — out of scope; future scheduled-fee-runner module owns the trigger).

EventBridge

Consumes: none in v1.

Publishes (catalogue v1, schema_version "1"): - bank.core.fee_assessed — every assessment (waived or posted) - bank.core.fee_posted — successful 2-leg journal commit - bank.core.fee_reversed — reversal commit

Consumers per catalogue: MOD-002, MOD-042, MOD-043.

SSM outputs

Path Type Purpose
/bank/{stage}/mod-110/api/base-url String HTTP API root
/bank/{stage}/mod-110/assess/url String Convenience URL
/bank/{stage}/mod-110/assess-lambda/arn String
/bank/{stage}/mod-110/reverse-lambda/arn String
/bank/{stage}/mod-110/list-lambda/arn String
/bank/{stage}/mod-110/fee-schedule-table String core.fee_schedule
/bank/{stage}/mod-110/fee-events-table String core.fee_events

Configuration

Env Default Purpose
TENANT_ID totara-bank Single-tenant in v1; multi-tenant later
EVENTBRIDGE_BUS_ARN (deploy-time) bank-core bus, resolved at deploy time

Policy mapping

Policy Mode How satisfied Test
CON-005 GATE notice_days CHECK ≥ 14 (V001, ADR-048 DB-enforced floor); resolver picks v1 while v2's effective_from is in the future. Reductions take effect at effective_from immediately (no gate). tests/policy/con-005-gate-notice-elapsed.test.ts + tests/integration/db-check-fee-schedule-notice-days.test.ts
CON-004 LOG Every fee event (assessed/waived/posted/reversed) recorded with full provenance. UPDATE/DELETE rejected by V003 trigger. tests/policy/con-004-log-immutable-fee-events.test.ts
PAY-001 AUTO 2-leg double-entry committed atomically with the fee_event row in one Postgres transaction. Sums to zero per transaction. ADR-048 deferred constraint trigger on accounts.postings is the structural backstop. tests/policy/pay-001-auto-double-entry.test.ts

ADR-048 DB-enforced invariants on core.fee_schedule

Invariant Enforcement Negative test
notice_days >= 14 V001 CHECK constraint tests/integration/db-check-fee-schedule-notice-days.test.ts
effective_to IS NULL OR effective_to > effective_from V006 CHECK constraint tests/integration/db-check-fee-schedule-temporal.test.ts
idempotency_key UNIQUE on core.fee_events V002 UNIQUE constraint tests/integration/fr-492-double-entry-atomic.test.ts (idempotent replay)
core.fee_events append-only V003 BEFORE UPDATE/DELETE/TRUNCATE trigger tests/policy/con-004-log-immutable-fee-events.test.ts

Known limitations / follow-ups

  • MOD-001 gate drift — orchestrator A14.a accepted. When MOD-005 (accruals) ships as the third writer to accounts.postings, lift status / currency / jurisdiction / sufficient-funds gates into @bank-core/shared/posting-gates so all three modules import the same code. Until then the gates are duplicated; document any new MOD-001 gate addition with a checklist for MOD-110 mirror update.
  • Reversal approval tier — orchestrator A5 deferred. v1 records authorised_by + reversal_reason without tier validation. TODO(MOD-109): add a configurable threshold table that requires manager approval above $X.
  • Fee-schedule administration — v1 has no admin API (orchestrator A11). Schedules inserted via psql / V005 seed. Future bank.core.fee_schedule_published event reserved for MOD-006's product-catalogue admin API.
  • Cron-driven fees — out of scope v1 (orchestrator A12). HTTP-triggered only. Future scheduled-fee-runner module fires monthly fees.
  • Staff/employee waiver — deferred (no parties table in bank-core yet). Five conditions shipped: zero_balance, negative_balance, recent_open, waiver_flag, promotional_period.
  • waiver_flag location — currently reads accounts.accounts.metadata->>'waiver_flag' (no metadata column on accounts.accounts yet, so always returns false in v1). When the column is added (cross-module ALTER), the read just starts returning real values without code changes.