feat(loans): Recompute EMI at the current rate, replacing Recompute accruals - #45
Merged
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Why the accruals button had to go
Every new loan is created on the EMI model:
createLoancallsfn_generate_emi_schedule, which setsrepayment_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 norepayment_modelguard and rebuilds accruals fromstart_datethrough 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_forfiltersrepayment_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.planEmiRecomputefilters onprincipal_paid/interest_paidrather thanstatus— the late-fee cron rewritesstatusbehind the app's back — andfn_reprice_emi_scheduleenforces the same predicate in SQL, so it holds even if the RPC is called directly.The confirmation screen
Built by the same
planEmiRecomputethe action applies, so the admin approves exactly what gets written:What stays
The legacy accrual backlog panel. Converted loans can still owe pre-cutover interest and hiding it would hide real money.
recomputeLoanAccrualsis 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 test323 passing ·npm run lintclean (one pre-existing warning) ·npm run buildpasses.Still open
fn_recompute_loan_accrualsremains callable by any admin session even though nothing links to it. Aand 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