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-gatesas 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
ScheduleExpressionTimezonefor 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_nonzeroCHECK forbids zero rows). Carry-forward is preserved on the prior row'sresidual_micros. Document this in operator runbook.
15. Architectural decisions captured here¶
- Direct-write under SD01 declared
ledger-direct-writepattern (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.tsmirroring MOD-001 / MOD-004 / MOD-110. Tracked as housekeeping debt. - Test-only
account_ids_filteron 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.