Skip to content

feat(loans): Recompute EMI at the current rate, replacing Recompute accruals - #45

Merged
parameshjava merged 3 commits into
mainfrom
feat/recompute-emi-at-current-rate
Aug 7, 2026
Merged

feat(loans): Recompute EMI at the current rate, replacing Recompute accruals#45
parameshjava merged 3 commits into
mainfrom
feat/recompute-emi-at-current-rate

Conversation

@parameshjava

Copy link
Copy Markdown
Owner

Replaces the "Recompute accruals" button with Recompute EMI, for the case that actually comes up: the interest rate changing. Includes migration 053_recompute_emi_schedule.sql.

Stacked on main. PR #44 is still open and also touches src/lib/actions/emi.ts — merge #44 first and this will need a small rebase (different functions in the same file, so no real conflict expected).

Why the accruals button had to go

Every new loan is created on the EMI model: createLoan calls fn_generate_emi_schedule, which sets repayment_model = 'emi'. The accrual track is history.

But the button rendered on every loan page, EMI included, and fn_recompute_loan_accruals (migration 020, written before the EMI model existed) has no repayment_model guard and rebuilds accruals from start_date through today. On a converted loan that would rewrite the frozen pre-cutover backlog and mint accrual rows for months the EMI schedule already charges interest for — interest counted twice in two systems. The monthly cron deliberately refuses to do this (fn_compute_loan_interest_for filters repayment_model = 'accrual', 039:373); the button was the only way in.

What Recompute EMI does

Re-amortizes the principal still owed on unpaid installments, over those same installments, at the current loan_interest_rate_pct.

  • Paid and part-paid installments are never modified. planEmiRecompute filters on principal_paid/interest_paid rather than status — the late-fee cron rewrites status behind the app's back — and fn_reprice_emi_schedule enforces the same predicate in SQL, so it holds even if the RPC is called directly.
  • Rows are updated in place, never deleted and reinserted. Installment numbers, due dates, late fees and every foreign key pointing at a row survive. Due dates especially: re-pricing must not move when the member has to pay.
  • Re-amortizing what's left, not regenerating from the original principal. That's what keeps it safe on a loan that has already had a prepayment — a regeneration would quietly undo it.

The confirmation screen

Built by the same planEmiRecompute the action applies, so the admin approves exactly what gets written:

  • the rate, EMI before → after, interest still to pay before → after, and how many installments move
  • a per-installment diff — # / due date / EMI / principal / interest, old struck through where it changes, unchanged rows greyed out
  • "No changes detected" when nothing moves, with nothing to apply
  • a plan fingerprint the action re-checks against a fresh read, so a schedule that shifted under the preview is refused rather than applied

What stays

The legacy accrual backlog panel. Converted loans can still owe pre-cutover interest and hiding it would hide real money. recomputeLoanAccruals is still in the codebase but is no longer reachable from the UI.

Testing

emi-recompute.test.ts — 12 cases over a real 1L / 8% / 12-month schedule: no-change detection, rate up and rate down, paid rows excluded, part-paid rows excluded even when the cron has flipped them to overdue, overdue rows included (unpaid, just late), waived skipped, due dates and numbers preserved, the re-priced tail clearing the balance exactly, both refusals, and fingerprint behaviour.

npm test 323 passing · npm run lint clean (one pre-existing warning) · npm run build passes.

Still open

fn_recompute_loan_accruals remains callable by any admin session even though nothing links to it. A and repayment_model = 'accrual' guard inside the function would close that properly — happy to add it, left out here as an unrequested schema change.

🤖 Generated with Claude Code

…ccruals

Every new loan is created on the EMI model — createLoan calls
fn_generate_emi_schedule, which sets repayment_model = 'emi' — so the accrual
track is history. The "Recompute accruals" button was still on every loan page,
including EMI ones, where fn_recompute_loan_accruals has no repayment_model
guard and rebuilds accruals through today: on a converted loan that rewrote the
frozen pre-cutover backlog and minted accrual rows for months the EMI schedule
already charges interest for. The monthly cron deliberately refuses to do this
(fn_compute_loan_interest_for filters repayment_model = 'accrual'); the button
was the only way in.

It is replaced with "Recompute EMI", for the case that actually comes up: the
interest rate changing. Re-pricing re-amortizes the principal still owed on
UNPAID installments, over those same installments, at the current
loan_interest_rate_pct.

  * Paid and part-paid installments are never modified — money has already been
    applied against their own figures. `planEmiRecompute` filters on
    principal_paid/interest_paid rather than `status`, since the late-fee cron
    rewrites status behind the app's back, and fn_reprice_emi_schedule
    (migration 053) enforces the same predicate in SQL.
  * Rows are UPDATED IN PLACE, never deleted and reinserted, so installment
    numbers, due dates, late fees and every foreign key pointing at a row
    survive. Due dates in particular: re-pricing must not move when the member
    has to pay.
  * Re-amortizing what is left, rather than regenerating from the original
    principal, is what keeps this safe on a loan that has already had a
    prepayment — a regeneration would quietly undo it.

Clicking it opens a confirmation screen built by the same planner the action
applies: the rate, EMI before → after, interest before → after, and a per
installment diff with unchanged rows greyed out. When nothing moves it says
"No changes detected" and offers nothing to apply. The plan carries a
fingerprint the action re-checks against a fresh read, so a schedule that moved
under the preview is refused rather than applied.

The legacy accrual backlog panel stays: converted loans can still owe
pre-cutover interest, and hiding it would hide real money. recomputeLoanAccruals
remains in the codebase but is no longer reachable from the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aits-fcf-tracker Ready Ready Preview Aug 7, 2026 12:40pm

…ialog

The recompute screen made the admin open a dialog and read a five-column diff
to answer one question: does this loan need re-pricing, and what does it do to
the member's EMI? The plan is already computed server-side for the page load,
so the card can just say.

The card now carries the state: an "Up to date" pill when every unpaid
installment is priced at the current rate, or "N installments out of date" with
the EMI move spelled out in words. The button follows — primary "Review new EMI"
when there is something to do, secondary "Recompute EMI" when there is not — and
the card hides itself entirely on a loan with nothing left to re-price.

Inside the dialog:

  * The EMI move leads, at display size, with the old figure struck through and
    the monthly difference signed and coloured by what it costs the member
    (amber for more, emerald for less). Everything else is supporting detail.
  * The table drops to four columns — installment, due date, EMI, interest.
    Principal is EMI minus interest and was not helping anyone decide.
  * It lists what CHANGES by default, with a toggle for the unchanged rows,
    rather than greying out noise the admin has to look past.
  * The old → new pairs carry an aria-label ("was X, now Y"), so the change is
    not conveyed by strikethrough and colour alone.
  * The no-changes state is a proper answer — "No changes detected", with all N
    installments confirmed at the current rate — instead of a bare sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… due

Ten findings from the review of this branch, six of them money-affecting. The
first three shared a root cause: re-amortizing the unpaid rows as a fresh
annuity assumed every installment is a uniform full month, which is not true.

The re-pricing is now exact rather than approximate. An installment's interest
is `balance × rate/12 × period`, which is LINEAR in the rate, so scaling it by
new/old reprices any row — a full month or the pro-rated stub a mid-month
disbursement produces — without having to tell them apart. Principal, opening
and closing balances are left alone entirely.

That kills three findings by construction:

  * A pro-rated stub is no longer rebuilt as a full month. Before, a loan
    disbursed mid-month reported every installment "out of date" at an
    UNCHANGED rate, and applying it charged a full month of interest for an
    11-day disbursement month.
  * The opening/closing chain can no longer break, because no principal moves.
    Before, a paid installment sitting between unpaid ones left row N's closing
    balance disagreeing with row N+1's opening balance, and charged interest on
    a balance that already netted off a later payment.
  * "No changes" is now exact for any schedule shape, not just clean annuities.

Already-due installments are excluded, restoring the invariant prepay-plan.ts
states: what the member already owes is never rewritten. Re-pricing an overdue
row also raised the late-fee target fn_apply_emi_late_fees derives from
emi_amount, re-billing months that had already elapsed. The SQL predicate
carries the same date guard.

The rest:

  * The fingerprint now carries both rates. It omitted the rate — the one value
    this feature is about — so a rate edit under an open preview sailed through
    and applied a diff nobody reviewed.
  * The write path no longer defaults the rate to 8 on a failed reference read.
    A read-path fallback is survivable; committing a guessed rate to the ledger
    is not (AGENTS.md: never hardcode a reference value).
  * fn_reprice_emi_schedule is all-or-nothing: it takes the count the app
    planned for and raises on a shortfall, so an installment paid between the
    read and the call can no longer be skipped silently, leaving a schedule
    mixing old and new rates.
  * loans.interest_rate_pct is only written after a re-pricing that matched
    rows, not unconditionally.
  * plan_fingerprint is required; missing means refuse, not proceed unguarded.
  * The headline is the interest total, not an EMI baseline read off the first
    unpaid row — which could be a stub or a balloon and produced nonsense
    deltas. The next installment's EMI is shown as a real, labelled row.
  * Accrual-model loans get their Recompute accruals button back. Removing it
    outright left them with no way to rebuild their accruals; it stays off EMI
    loans, which is where it was dangerous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@parameshjava
parameshjava merged commit 1d16499 into main Aug 7, 2026
3 checks passed
@parameshjava
parameshjava deleted the feat/recompute-emi-at-current-rate branch August 7, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant