Skip to content

fix(loans): anchor EMI schedules at the cutover + admin transaction edit/delete - #39

Merged
parameshjava merged 3 commits into
mainfrom
fix/emi-cutover-anchor
Aug 6, 2026
Merged

fix(loans): anchor EMI schedules at the cutover + admin transaction edit/delete#39
parameshjava merged 3 commits into
mainfrom
fix/emi-cutover-anchor

Conversation

@parameshjava

Copy link
Copy Markdown
Owner

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_date on its outstanding principal. convertToEmi got that right, but nothing persisted the anchor — so every later regeneration re-derived it from loans.start_date and loans.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.

prepayLoan then anchored its rebuilt tail at next_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 penalty transactions 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.sql puts 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 at 20260701). 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 from loans_balances, not the amount originally lent.
  • prepayLoan — anchors the tail at tenthOfMonth(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.sqlrun migration 051 first.

  1. Read-only diagnostic listing affected loans.
  2. Per-loan transactional repair: reverses the bogus late fees (balancing negative penalty entries; originals kept for audit), deletes the back-dated rows, rebuilds from the cutover on the current outstanding.
  3. Re-run the diagnostic to confirm zero.

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).

  • Bank reference capped + truncated (full value in a title), which alone brings the table back inside the viewport.
  • overflow-clipoverflow-x-auto, so far-right columns are scrollable rather than silently unreachable.
  • 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 doesn't trap it.

Verification

  • npm test — 278 passed (16 new: emi-anchor.test.ts, plus prepayment-anchor cases in emi-math.test.ts)
  • npm run lint — clean (one pre-existing warning in pending-interest-panel.tsx)
  • npm run build — passes

🤖 Generated with Claude Code

parameshjava and others added 2 commits August 6, 2026 22:02
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>
@vercel

vercel Bot commented Aug 6, 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 6, 2026 4:40pm

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>
@parameshjava
parameshjava merged commit d4457d9 into main Aug 6, 2026
2 of 3 checks passed
@parameshjava
parameshjava deleted the fix/emi-cutover-anchor branch August 6, 2026 16:43
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