Skip to content

MOD-005 — Daily accrual calculator

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-005-daily-accrual-calculator/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048


1. Purpose

End-of-day, computes daily interest for every active interest-bearing account and posts a 2-leg journal per account against accounts.postings. Reads the rate active on the accrual date from accounts.interest_rates. Generates a per-run reconciliation summary (FR-063). Supports retroactive correction postings (FR-064) that reference the original accrual via corrects_accrual_posting_id.

v1 product scope (orchestrator Q2): - Savings — CREDIT customer: NZ_SAVINGS_01, AU_SAVINGS_01 — positive balance × BASE rate, customer credited. - Overdraft — DEBIT customer: NZ_TRANSACTION_01, AU_TRANSACTION_01 when balance < 0 — negative balance × OVERDRAFT rate, customer debited.

Out of v1: term deposits (MOD-111, phase 6), loan amortisation (MOD-112, phase 7), trust + community accounts (rate=0).

2. Architecture

EventBridge schedule (NZ 11:55 UTC / AU 13:55 UTC) ──> Mod005RunAccrualHandler
HTTP POST /internal/v1/accrual-runs              ──┘
                                          startRun → core.accrual_runs
                              iterateEligibleAccounts (per-jurisdiction +
                                                  product_code IN (...) cap
                                                  + optional account_ids_filter)
                                  per account, in its own transaction:
                                    1. eligibility-pure
                                    2. rate-store.loadActiveRate (FR-062)
                                    3. inline posting gates (Q4)
                                    4. accrual-math-pure.computeDailyAccrual
                                       (residual-micros carry-forward)
                                    5. INSERT 2-leg accounts.postings
                                    6. UPDATE balance + version on both legs
                                    7. INSERT core.accrual_postings (signed amount)
                                  completeRun → totals + by_product + variance flags
                            EventBridge bank-core: bank.core.accrual_run_completed v1


  HTTP POST /internal/v1/accrual-corrections (FR-064)
    ───────────────────────────────────────────────────────────────────
    1. Replay check by idempotency_key
    2. Load original accrual_posting + locks
    3. Reverse the original posting → 2-leg accounts.postings (reverses_posting_id set)
    4. Apply reverse balance deltas
    5. Compute corrected accrual using supplied rate
    6. Post the corrected accrual → 2-leg accounts.postings
    7. INSERT CORRECTION run (correction_of_run_id linked) + 2 accrual_postings rows
    8. EventBridge: bank.core.accrual_correction_posted v1

Module type

Application Lambda module. 3 handlers (run-accrual, post-correction, get-run-summary) + 2 EventBridge schedules (NZ + AU).

3. Data model

accounts.interest_rates (V001 — owned by MOD-005 until MOD-006 ships)

Per SD01 register. MOD-005 reads; MOD-006 will own UPDATEs once it ships. ADR-048 CHECKs from V001 inline:

Column Notes
product_code NOT NULL FK
rate_type NOT NULL CHECK ∈ (BASE, BONUS, PENALTY, OVERDRAFT, FIXED_LENDING, VARIABLE_LENDING)
annual_rate NOT NULL CHECK (annual_rate >= 0)
effective_from NOT NULL
effective_to CHECK NULL OR > effective_from
tp_rate_bps, margin_bps, lvr_min, lvr_max, credit_tier lending-only fields; null for v1 retail

V004 seeds initial rows for the v1 scope with effective_from = '2020-01-01' so historical-date queries (audit, integration tests) all resolve.

core.accrual_runs (V002) — one row per run

Identity fields (immutable per V003 trigger): run_id, tenant_id, jurisdiction, accrual_date, run_type, period_start, period_end, correction_of_run_id, started_at, idempotency_key, trace_id, correlation_id, actor_*.

Mutable completion fields (set by completeRun): status, completed_at, accounts_*, interest_credited, interest_charged, by_product, variance_flags.

Coherence CHECKs: - chk_accrual_run_dates: period_end >= period_start (Q8) - chk_accrual_run_completion_coherence: completed_at populated iff status terminal - chk_accrual_run_correction_link_iff_correction: CORRECTION rows reference original DAILY run

core.accrual_postings (V002) — one row per (account, accrual_date)

Column Notes
accrual_posting_id uuid PK
run_id, account_id, product_code identity
accrual_date, principal_cents, annual_rate, rate_type, day_count_basis inputs captured for audit
amount numeric(18,2) signed CHECK (amount <> 0) — positive for savings credits, negative for overdraft debits (Q6/Q8)
currency char(3)
residual_micros bigint NOT NULL DEFAULT 0 — carry-forward sub-cent balance (Q6)
posting_id links to accounts.postings
corrects_accrual_posting_id self-FK; null for DAILY rows, set for CORRECTION rows

UNIQUE partial index on (account_id, accrual_date) WHERE corrects_accrual_posting_id IS NULL prevents double-accrual on the daily path; corrections allowed multiple per (account, date) because they reference the original.

Append-only via V003 immutability triggers (Cat 1).

Cross-module ALTERs

None. V004 only INSERTs into existing accounts.account_products + accounts.accounts for the four new internal GL accounts:

  • INTERNAL_INTEREST_EXPENSE_NZ (NZD/NZ) — DEBIT leg for savings interest (bank's expense)
  • INTERNAL_INTEREST_EXPENSE_AU (AUD/AU)
  • INTERNAL_INTEREST_INCOME_NZ (NZD/NZ) — CREDIT leg for overdraft interest (bank's income)
  • INTERNAL_INTEREST_INCOME_AU (AUD/AU)

4. ADR-048 DB-enforced invariants register

Item Migration Negative test
core.accrual_postings immutability triggers (UPDATE/DELETE/TRUNCATE) V003 tests/integration/db-trigger-accrual-postings-immutable.test.ts
core.accrual_runs partial immutability (DELETE/TRUNCATE rejected; UPDATE on identity fields rejected; status/totals updates allowed) V003 same file
core.accrual_runs chk_accrual_run_dates (period_end ≥ period_start) V002 tests/integration/db-check-accrual-constraints.test.ts
core.accrual_postings chk_accrual_posting_amount_nonzero (amount ≠ 0) V002 same file
accounts.interest_rates annual_rate >= 0 + temporal CHECK V001 covered indirectly by rate-store tests

5. Accrual math (pure)

Day-count basis: ACT/365 (orchestrator Q1 — confirmed; documented in config.ts for future per-product override).

daily_micros = principal_cents × annual_rate × 1000 / 365  (banker's rounded)
total_micros = daily_micros + carry_in_micros
posted_cents = banker's-round(total_micros / 1000)
carry_out_micros = total_micros - posted_cents × 1000

All in fixed-point bigint to avoid float error. Banker's rounding (HALF_EVEN) used consistently. The carry can be negative (a "borrow forward") when banker's rounding pushes the posted cent beyond the total — this is mathematically correct sub-cent bookkeeping; the next day's accrual subtracts the borrow and the cumulative sum reconciles.

Sign convention: - The math works in absolute magnitude (positive principal). - accrual-poster.ts re-applies sign at the write boundary based on direction: CREDIT (savings) → positive signed amount; DEBIT (overdraft) → negative signed amount. - accounts.postings.amount is always positive (CHECK > 0); the entry_type column carries direction. - core.accrual_postings.amount is signed so SUM(amount) reconciles directly to net interest paid.

6. Eligibility (FR-061 + Q2 + Q10)

Status Eligible?
ACTIVE ✅ accrue
RESTRICTED ✅ accrue (interest accrues during restriction; the restriction is on debit activity, not on the contractual obligation to pay interest)
PENDING ❌ skip
DORMANT ❌ skip (MOD-008 lifecycle territory)
CLOSED ❌ skip
Product + balance Direction
*_SAVINGS_01 + balance > 0 CREDIT, BASE rate
*_SAVINGS_01 + balance ≤ 0 skip (no positive balance to accrue on)
*_TRANSACTION_01 + balance < 0 DEBIT, OVERDRAFT rate
*_TRANSACTION_01 + balance ≥ 0 skip (no overdraft to accrue on)

Internal accounts (is_internal=true) are excluded from iteration so the GL accounts seeded by V004 never appear in the run set.

7. Run scheduling (FR-061 + Q5)

Two EventBridge schedules: - NZ: cron(55 11 * * ? *) UTC = 23:55 NZST (NZST = UTC+12) - AU: cron(55 13 * * ? *) UTC = 23:55 AEST (AEST = UTC+10)

The 5-minute buffer before midnight allows the run to complete and emit before the calendar day rolls.

The scheduled payload uses the sentinel accrual_date: 'TODAY' — the handler resolves to the current jurisdiction-local date so EventBridge retries on the same wall-clock day land on the same accrual_date (idempotent).

DST follow-up (Q5 — known follow-up): the crons are pinned to standard time; during DST in either jurisdiction the schedule fires at 22:55 local instead of 23:55. Production-grade fix: switch to EventBridge Scheduler's ScheduleExpressionTimezone so the cron floats with local DST. Tracked in the handoff.

8. Reconciliation (FR-063)

completeRun writes per-run totals to core.accrual_runs: - accounts_processed / posted / skipped / errored - interest_credited (savings credits) - interest_charged (overdraft debits) - by_product jsonb breakdown — per product code: {count, amount} - variance_flags jsonb array — flagged accounts where actual posted amount deviated from the expected formula by > 0.01 cents (default; configurable via VARIANCE_THRESHOLD_HUNDREDTH_CENTS env var).

Variance check derives expected from same math but with carryIn=0 to detect rounding bugs. Day-1 accruals (no prior carry) match exactly; day-2+ may legitimately differ by sub-cent due to carry, so the threshold filters those out.

9. FR-064 retroactive correction

POST /internal/v1/accrual-corrections body:

{
  "original_accrual_posting_id": "uuid",
  "reason": "rate change applied retrospectively",
  "corrected_annual_rate": "0.050000",
  "authorised_by": "compliance-officer-01",
  "idempotency_key": "...",
  "actor_kind": "staff",
  "actor_id": "..."
}

Flow: 1. Idempotent replay — checks (idempotency_key + corrects_id) for an existing CORRECTED row with metadata->>'kind' = 'CORRECTED'. 2. Reverses the original posting in accounts.postings (reverses_posting_id set; opposite entry_type). 3. Computes corrected accrual at supplied rate. 4. Posts the corrected accrual. 5. Writes a CORRECTION run row with correction_of_run_id = original.run_id + 2 accrual_postings rows (metadata->>'kind' ∈ {REVERSAL, CORRECTED}).

Manual-trigger only in v1 (Q7); scheduled retry logic out of scope.

10. SSM outputs

Path Value
/bank/{stage}/mod-005/api/base-url API Gateway base URL
/bank/{stage}/mod-005/lambdas/{run-accrual,post-correction,get-run-summary}/arn per-handler ARN
/bank/{stage}/mod-005/tables/{interest-rates,accrual-runs,accrual-postings}/name table FQNs

11. Cross-module touches

None. Per orchestrator Q4: shared posting-gates extraction is deferred. MOD-005 implements its own posting-gate checks inline: - account.status === 'ACTIVE' or 'RESTRICTED' - currency match (leg.currency === account.currency) - jurisdiction match - direction-aware: DEBIT to RESTRICTED rejected (mirrors MOD-007 V005 trigger; defence in depth)

These gates duplicate what MOD-001/004/110 already implement. The shared posting-gates lib is a tracked tech-debt item — to be picked up before phase-5 credit modules ship (MOD-005's pattern + the four duplicates → six callers will need it).

12. Policies satisfied

Policy Mode How satisfied Test
REP-004 AUTO Every accrual_postings row links to an accounts.postings row; interest_credited on the run row matches SUM(accounts.postings.amount WHERE entry_type='CREDIT') for the run's seeded customer accounts. tests/policy/rep-004-auto-ifrs9-accrual.test.ts
CON-005 AUTO Banker's rounding (HALF_EVEN) used consistently; residual-micros carry forward eliminates systematic per-account rounding error. Portfolio reconciliation: SUM(accrual_postings.amount) = interest_credited - interest_charged. tests/policy/con-005-auto-no-rounding-manipulation.test.ts
CLQ-004 CALC Every accrual posting carries product_code + rate_type + annual_rate on both core.accrual_postings and accounts.postings.metadata so downstream rate-sensitivity queries (MOD-031) derive the position from postings alone. tests/policy/clq-004-calc-rate-sensitive-position.test.ts
CRE-006 AUTO Same effective rate applied consistently across all same-product accounts. Two accounts with identical balance + product get identical accrual amounts. tests/policy/cre-006-auto-effective-interest-method.test.ts

13. Test approach + results

Tier Files Result
Unit tests/unit/{accrual-math-pure, eligibility-pure, errors, logger, emf}.test.ts 45 / 45
Contract tests/contract/accrual-events.test.ts 4 / 4
FR integration tests/integration/fr-{061,062,063,064}-*.test.ts 10 / 10
ADR-048 negative tests/integration/db-{trigger,check}-*.test.ts 9 / 9
Policy tests/policy/{rep-004, con-005, clq-004, cre-006}-*.test.ts 6 / 6

Combined MOD-005: 49 unit/contract + 25 integration/policy = 74/74.

Tests use a uniqueAccrualDate() helper + account_ids_filter parameter on runAccrualBatch so concurrent vitest workers don't fight on the UNIQUE (account_id, accrual_date) constraint. The filter is test-only (production runs leave it unset → full eligible-account scan).

14. Known follow-ups + tech debt

  • Shared posting-gates lib (HIGH PRIORITY before phase-5 credit modules) — MOD-005 makes 4 modules with duplicated inline posting gates (status / currency / jurisdiction / sufficient-funds). Lift to @bank-core/shared/posting-gates as a standalone housekeeping task.
  • DST-aware EventBridge schedules — current crons pinned to standard time; will fire at 22:55 local during DST. Switch to EventBridge Scheduler ScheduleExpressionTimezone for floating local-time cron.
  • MOD-006 ownership of accounts.interest_rates — when MOD-006 ships it takes over UPDATE writes; MOD-005 stays the reader.
  • Dormant-account accrual — MOD-008 lifecycle territory. v1 skips DORMANT; revisit when MOD-008's escheatment path interacts with interest accrual semantics (industry varies — some banks suspend accrual at dormancy, others continue until escheatment).
  • NFR-006 production scaling — v1 is single-Lambda iteration with per-account transactions. Production-scale (1M+ accounts) needs parallel SQS-driven worker fanout. Out of v1 scope.
  • Sub-cent skip semantics — when posted_cents rounds to 0 we skip the row entirely (the chk_accrual_posting_amount_nonzero CHECK forbids zero rows). Carry-forward is preserved on the prior row's residual_micros. Document this in operator runbook.

15. Architectural decisions captured here

  • Direct-write under SD01 declared ledger-direct-write pattern (Q3). Per-account 2-leg postings in a single Postgres transaction; no HTTP hop to MOD-001. SD01's seven contract conditions all met: app_user role, source_module populated, balance + version updated with FOR UPDATE, idempotency UNIQUE, MOD-002 ingest accepts the source_module. The runner posts per-account to isolate failures.
  • Residual-micros carry-forward (Q6) for sub-cent precision. Banker's rounding daily (option a) would introduce a systematic per-account error invisible to operators. Residuals are fully auditable on core.accrual_postings.residual_micros (signed bigint, default 0).
  • Inline posting gates (Q4 — defer shared lib). Status / currency / jurisdiction checks duplicated in accrual-poster.ts mirroring MOD-001 / MOD-004 / MOD-110. Tracked as housekeeping debt.
  • Test-only account_ids_filter on the runner. Production runs iterate full eligible-account set; tests scope to seeded accounts to avoid concurrent-vitest contention on the UNIQUE constraint
  • dev-data accumulation slowing test runs.
  • Static + scheduler-injected accrual_date — handler resolves 'TODAY' sentinel to current jurisdiction-local date so EventBridge retries on the same wall-clock day land on the same accrual_date.