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:
- WAIVED → posted_amount IS NULL, posting_id IS NULL, waived = true, waiver_reason set
- POSTED → posted_amount IS NOT NULL, posting_id IS NOT NULL, waived = false
- REVERSED → reverses_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-gatesso 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_reasonwithout 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_publishedevent 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
partiestable in bank-core yet). Five conditions shipped: zero_balance, negative_balance, recent_open, waiver_flag, promotional_period. waiver_flaglocation — currently readsaccounts.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.