22. Late-payment interest + invoice lifecycle
Date: 2026-06-06
Status
Accepted
Context
Invoices were created draft with no way to advance them, so "overdue" and interest had nothing to
act on. Step 15 adds the manual invoice lifecycle (Gmail auto-send is deferred) and corrected
late-payment interest. The build plan §4 flagged three bugs in the spec's naive accrual.
Decision
- Lifecycle (owner-gated, manual):
markInvoiceSent(draft → sent),recordInvoicePayment(adds toamount_paid_cents, flips topartial/paid, setspaid_manually+paid_at). Payment recording uses optimistic concurrency (where amount_paid_cents = <read value>) so two concurrent payments can't lose an update. Overdue is computed at read time (statussent/partialpastdue_date) — no stored flip needed. - Interest engine (
src/lib/interest, pure, tested), §4 corrections:- Charged on the outstanding balance (
total − paid), never on a figure that already includes a prior interest line — so recomputation can't compound. - Real elapsed days past (
due_date+ grace), not a fixed ÷30. Amonthlyrate is converted to a true daily rate (× 12 ÷ 365); adailyrate is used as-is. - Idempotent storage:
accrueInvoiceInterestwritesinterest_accrued_cents(SET, not increment) plusinterest_basis_centsandinterest_calculated_at, so re-running is safe. - Rate/grace/type come from the facility (
interest_enabled,interest_rate,interest_rate_type,interest_grace_period_days). Disabled facility → accrued 0.
- Charged on the outstanding balance (
- Owner waiver → audit:
waiveInterest(owner only) setsinterest_waived+interest_waived_byinterest_waived_at+ note; the invoices audit trigger records it, andinterest_waived_bycaptures who. A waived invoice's amount-due excludes interest.
- UI: a per-invoice
Managepanel on/admin/invoicesshows amount-due-with-interest and the contextual controls (mark sent, record payment, refresh interest, waive). Money is integer USD cents throughout; no funding/email call is introduced.
Consequences
- The invoice now has a real lifecycle (draft → sent → partial/paid, with overdue computed and interest accruable), which makes the dashboard's overdue/outstanding figures meaningful.
- Interest is recompute-on-demand here; the scheduled nightly accrual is wired in Step 23 (cron),
reusing the same
computeInterest+accrueInvoiceInterest. - Deferred: automatic "mark sent" via Gmail (Step 12); check-reconciliation payments (Step 13) will call the same payment path. Net/withholding still out of scope (gross only).