fix(loans): anchor EMI schedules at the cutover + admin transaction edit/delete - #39
Merged
Conversation
The Actions column was rendering but unreachable: the table was wider than its container and the wrapper used overflow-clip, which permits no scrolling. The width came from the uncapped bank-reference sub-line under Transaction ID (full NEFT/UPI narrations, whitespace-nowrap). - Cap + truncate the bank reference (full value in a title), which alone brings the table back inside the viewport. - overflow-clip -> overflow-x-auto so far-right columns are scrollable rather than silently unreachable on a narrow window. - Actions column now offers Edit and Delete inline (new enableRowDelete prop; read-only dashboard surfaces keep the plain Manage link). Delete goes through a PrDialog confirm and the same deleteTransaction action the manage page uses. The dialog portals to body, so the new scroll container does not trap it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PRODUCTION INCIDENT A loan converted from accrual to EMI is scheduled from emi_cutover_date on its OUTSTANDING principal. Neither value was persisted, so any later regeneration re-derived both from loans.start_date and principal_amount: * updateLoan — fired on ANY edit of an EMI loan, even notes-only * recalculateSchedule — the Recalculate button Since 044 the generator upserts in place with due_date = excluded.due_date, so those calls rewrote every unsettled row's due date backwards and re-amortized the full original principal. prepayLoan then anchored its rebuilt tail at next_due_date — by then a 2025 date — compounding it on every prepayment. The back-dated rows aged past the grace period and the late-fee cron charged real penalty transactions against them. FIX - Migration 051: the floor now lives inside fn_generate_emi_schedule as greatest(p_start, emi_cutover_date), so no caller can bypass it. The interest waiver is zeroed when the floor engages — it belongs to the original disbursement, spent long before the cutover. - updateLoan: regenerates only when a schedule-shaping field actually changed, and refuses once any installment is settled (use Prepay). - updateLoan + recalculateSchedule: a pre-cutover loan schedules the outstanding principal, not the amount originally lent. - prepayLoan: anchors the tail at tenthOfMonth(paidDate, 1), so it can never emit a past-dated installment. Earlier unpaid rows fold in; their principal is already inside the outstanding, so nothing is written off. Also stops silently forgiving late fees already charged on the rows it deletes — the unwaived total carries onto the first new installment. - src/lib/emi-anchor.ts mirrors the date/principal rules as pure functions, with tests. scripts/prod/fix-emi-backdated-schedules.sql repairs affected loans: diagnose, reverse the bogus late fees (originals kept for audit), delete the back-dated rows, rebuild from the cutover on the current outstanding. Requires 051 to be applied first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The loan number appeared in two places (the DO block and the verification SELECT), so updating one and missing the other failed with "No loan with loan_number ...". Both now read from a temp table set once at the top, defaulted to the loan affected in production (202503-003). 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.
Two changes. The EMI one is a production incident fix and includes a repair script.
1. EMI schedules were being regenerated from the loan's original start date
What went wrong
A loan converted from the accrual model to EMI is scheduled from
emi_cutover_dateon its outstanding principal.convertToEmigot that right, but nothing persisted the anchor — so every later regeneration re-derived it fromloans.start_dateandloans.principal_amount:updateLoan— fired on any edit of an EMI loan, even a notes-only change. No guard.recalculateSchedule— the "Recalculate" button.Since migration 044 the generator upserts in place with
due_date = excluded.due_date, so those calls rewrote every unsettled row's due date to a back-dated one and re-amortized the full original principal, ignoring repayments already made.prepayLoanthen anchored its rebuilt tail atnext_due_date— the earliest unpaid due date, by then a 2025 date — regenerating into the past and compounding the damage on every prepayment.The back-dated rows aged past the 2-month grace period, so the late-fee cron charged them — creating real
penaltytransactions against installments that should never have existed.Observed in production: installment #1 due
2025-10-10, pending principal back at the original ₹1,00,000, late fees of ₹1,392 / ₹1,218 / ₹1,044.The fix
051_emi_schedule_cutover_floor.sqlputs the floor inside the generator, so no caller can bypass it:v_start := greatest(p_start, emi_cutover_date)Read from
public.reference(cutover unchanged at20260701). When the floor engages the interest waiver is zeroed — a waiver belongs to the original disbursement and was spent long before the cutover.App changes:
updateLoan— regenerates only when a schedule-shaping field actually changed, and refuses once any installment is settled, directing to Prepay. A notes edit no longer touches the schedule.updateLoan+recalculateSchedule— a pre-cutover loan schedules the outstanding principal fromloans_balances, not the amount originally lent.prepayLoan— anchors the tail attenthOfMonth(paidDate, 1), so a prepayment on 2026-08-06 starts 2026-09-10 and can never emit a past-dated installment. Earlier unpaid rows fold in; their principal is already inside the outstanding, so nothing is written off.prepayLoan— also stops silently forgiving late fees already charged on the rows it deletes; the unwaived total carries onto the first new installment.src/lib/emi-anchor.ts— the date/principal rules as pure functions mirroring the SQL, with tests.Repairing production
scripts/prod/fix-emi-backdated-schedules.sql— run migration 051 first.It refuses to run on a loan with settled installments — reshaping a part-repaid schedule is what Prepay is for.
2. Admin transactions: edit/delete from the list
The Actions column was rendering but unreachable — the table was wider than its container and the wrapper used
overflow-clip, which permits no scrolling. The width came from the uncapped bank-reference sub-line under Transaction ID (full NEFT/UPI narrations,whitespace-nowrap).title), which alone brings the table back inside the viewport.overflow-clip→overflow-x-auto, so far-right columns are scrollable rather than silently unreachable.enableRowDeleteprop; read-only dashboard surfaces keep the plainManage →link). Delete goes through aPrDialogconfirm and the samedeleteTransactionaction the manage page uses. The dialog portals tobody, so the new scroll container doesn't trap it.Verification
npm test— 278 passed (16 new:emi-anchor.test.ts, plus prepayment-anchor cases inemi-math.test.ts)npm run lint— clean (one pre-existing warning inpending-interest-panel.tsx)npm run build— passes🤖 Generated with Claude Code