From c2e4ab93e62d96ee1828d8ce1bed03b35a755905 Mon Sep 17 00:00:00 2001 From: Paramesh Korrakuti Date: Thu, 6 Aug 2026 22:02:34 +0530 Subject: [PATCH 1/3] feat(admin): edit/delete transactions 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). - 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) --- src/app/(app)/admin/transactions/page.tsx | 1 + src/components/transaction-row-actions.tsx | 87 ++++++++++++++++++++++ src/components/transactions-table.tsx | 30 ++++++-- 3 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/components/transaction-row-actions.tsx diff --git a/src/app/(app)/admin/transactions/page.tsx b/src/app/(app)/admin/transactions/page.tsx index 330ef63..a2dca2a 100644 --- a/src/app/(app)/admin/transactions/page.tsx +++ b/src/app/(app)/admin/transactions/page.tsx @@ -41,6 +41,7 @@ export default async function AdminTransactionsListPage() { ) diff --git a/src/components/transaction-row-actions.tsx b/src/components/transaction-row-actions.tsx new file mode 100644 index 0000000..2f0fa00 --- /dev/null +++ b/src/components/transaction-row-actions.tsx @@ -0,0 +1,87 @@ +'use client' + +import Link from 'next/link' +import { useActionState, useState } from 'react' +import { deleteTransaction } from '@/lib/actions/transactions' +import { PrDialog } from '@/components/ui/pr/dialog' + +/** + * Per-row Edit / Delete controls for the admin transactions list. + * + * Edit navigates to the transaction's manage page (the full form). Delete is + * handled inline behind a confirm dialog so correcting a mis-keyed row doesn't + * cost two page loads — it calls the same `deleteTransaction` action the manage + * page uses, which redirects back to /admin/transactions on success (a no-op + * navigation from here that re-renders the list without the deleted row). + */ +export function TransactionRowActions({ + id, + transactionId, + editHref, +}: { + id: string + transactionId: string + editHref: string +}) { + const [open, setOpen] = useState(false) + // Only ever read on the error path — the success path redirects, so this + // component unmounts before the state could be shown. On error the dialog + // stays open (we never call setOpen(false) there) so the message is visible. + const [state, action, pending] = useActionState( + async (_prev: unknown, formData: FormData) => deleteTransaction(formData), + null, + ) + + return ( + + + Edit + + + + + setOpen(false)} + header="Delete this transaction?" + footer={ + <> + +
+ + +
+ + } + > +

+ Permanently removes {transactionId}. This action + cannot be undone. +

+ + {state && !state.ok &&

{state.error}

} +
+
+ ) +} diff --git a/src/components/transactions-table.tsx b/src/components/transactions-table.tsx index 0baa47e..9f73727 100644 --- a/src/components/transactions-table.tsx +++ b/src/components/transactions-table.tsx @@ -5,6 +5,7 @@ import { Dropdown } from 'primereact/dropdown' import { formatRupees } from '@/lib/format' import { PollModal } from '@/components/poll-modal' import { TableExportMenu } from '@/components/table-export' +import { TransactionRowActions } from '@/components/transaction-row-actions' import { PrDataTable, type PrColumn } from '@/components/ui/pr/data-table' import type { Cell, ExportCriterion } from '@/lib/table-export' @@ -76,6 +77,7 @@ export function TransactionsTable({ enableSearch = true, memberColumnLabel = 'Member', showDonationColumns = false, + enableRowDelete = false, exportName = 'transactions', exportTitle = 'Transactions', exportCriteria = [], @@ -100,6 +102,9 @@ export function TransactionsTable({ * "Beneficiary" (from `beneficiary_name`) and "Poll" (from `poll`). * Used by the /dashboard/donations section view. */ showDonationColumns?: boolean + /** Admin-only: render Delete beside Edit in the Actions column, confirmed by + * a dialog. Read-only surfaces (dashboard sections) leave this off. */ + enableRowDelete?: boolean }) { const showActions = rows.some((r) => !!r.manage_href) @@ -297,7 +302,13 @@ export function TransactionsTable({
{t.transaction_id}
{t.bank_transaction_id && ( -
+ // Bank references run long (full NEFT/UPI narrations). Capped and + // truncated — uncapped, this one cell widened the table past the + // viewport and pushed the Actions column out of reach. +
{t.bank_transaction_id}
)} @@ -320,15 +331,21 @@ export function TransactionsTable({ align: 'right', bodyClassName: 'whitespace-nowrap text-right', body: (t: TxnRowAug) => - t.manage_href ? ( + !t.manage_href ? ( + + ) : enableRowDelete ? ( + + ) : ( Manage → - ) : ( - ), }, ] as PrColumn[]) @@ -349,7 +366,10 @@ export function TransactionsTable({ ) return ( -
+ // `overflow-x-auto` rather than `overflow-clip`: on a narrow viewport the + // rightmost columns (Description, Actions) exceed the container, and clip + // would silently make them unreachable instead of offering a scrollbar. +
value={augmented} columns={columns} From cea9a60fa0ee383d33a65170e83f78fbd7c9c658 Mon Sep 17 00:00:00 2001 From: Paramesh Korrakuti Date: Thu, 6 Aug 2026 22:02:46 +0530 Subject: [PATCH 2/3] fix(loans): anchor EMI schedules at the cutover, not the loan start date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/prod/fix-emi-backdated-schedules.sql | 229 ++++++++++++++++++ .../051_emi_schedule_cutover_floor.sql | 198 +++++++++++++++ src/lib/actions/emi.ts | 65 ++++- src/lib/actions/loans.ts | 89 +++++-- src/lib/emi-anchor.test.ts | 57 +++++ src/lib/emi-anchor.ts | 44 ++++ src/lib/emi-math.test.ts | 23 ++ 7 files changed, 685 insertions(+), 20 deletions(-) create mode 100644 scripts/prod/fix-emi-backdated-schedules.sql create mode 100644 scripts/prod/migrations/051_emi_schedule_cutover_floor.sql create mode 100644 src/lib/emi-anchor.test.ts create mode 100644 src/lib/emi-anchor.ts diff --git a/scripts/prod/fix-emi-backdated-schedules.sql b/scripts/prod/fix-emi-backdated-schedules.sql new file mode 100644 index 0000000..ab0e996 --- /dev/null +++ b/scripts/prod/fix-emi-backdated-schedules.sql @@ -0,0 +1,229 @@ +-- ============================================================================= +-- FCF Tracker — repair: EMI schedules that were regenerated from the loan's +-- original start date instead of the EMI cutover. +-- +-- WHAT WENT WRONG +-- A loan converted from the accrual model to EMI is scheduled from +-- `emi_cutover_date` on its OUTSTANDING principal. Nothing persisted that +-- anchor, so a later `updateLoan` (any edit — even notes) or "Recalculate" +-- regenerated it from `loans.start_date` and `loans.principal_amount`. +-- 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. +-- +-- Symptom seen in production: installment #1 due 2025-10-10, pending +-- principal back at ₹1,00,000, and late fees charged on months that never +-- should have existed. Those late fees created real `penalty` transactions. +-- +-- Migration 051 stops this recurring (the cutover floor now lives inside +-- fn_generate_emi_schedule). This script cleans up rows written before it. +-- +-- WHAT THIS SCRIPT DOES, per affected loan +-- 1. Reverses every late fee charged on a back-dated installment — a +-- balancing negative `penalty` transaction, mirroring the waiver flow in +-- payEmi. The original charge is KEPT so the audit trail stays intact and +-- the pair nets to zero. +-- 2. Deletes the back-dated unsettled installments. +-- 3. Rebuilds the schedule from the cutover on the CURRENT outstanding +-- principal, via fn_generate_emi_schedule (051 or later). +-- +-- SAFETY +-- * Only `scheduled` / `overdue` rows are touched. Anything settled +-- (paid / partially_paid / waived) is left exactly as it is, and step 3 +-- aborts if a settled row exists — a part-repaid schedule must be reshaped +-- through Prepay, not rebuilt underneath the payments. +-- * Every step is a transaction you COMMIT or ROLLBACK yourself. +-- * Re-running is harmless: the second pass finds nothing to fix. +-- +-- Run migration 051 FIRST. Then run this as the Supabase SQL editor's default +-- role (owner; bypasses RLS). +-- ============================================================================= + + +-- ---------------------------------------------------------------------------- +-- STEP 1 — Diagnose. Read-only; run this first and eyeball the output. +-- +-- Lists every EMI loan holding unsettled installments dated before the cutover +-- month's first due date (the cutover month + 1, on the 10th). Those are the +-- back-dated rows. +-- ---------------------------------------------------------------------------- +with cutover as ( + select to_date(trunc(value)::bigint::text, 'YYYYMMDD') as d + from public.reference where key = 'emi_cutover_date' +), +first_legit_due as ( + -- The earliest due date a correctly-anchored schedule can have: the 10th of + -- the month after the cutover month. + select (date_trunc('month', d) + interval '1 month' + interval '9 days')::date as d + from cutover +) +select + l.loan_number, + m.name as member_name, + l.start_date, + (select d from cutover) as cutover_date, + l.principal_amount as original_principal, + lb.pending_principal as outstanding_now, + count(*) filter (where s.due_date < (select d from first_legit_due)) + as backdated_rows, + min(s.due_date) as earliest_due, + count(*) filter (where s.status in ('paid','partially_paid','waived')) + as settled_rows, + coalesce(sum(s.late_fee_charged) filter ( + where s.due_date < (select d from first_legit_due) + and not coalesce(s.late_fee_waived, false) + ), 0) as bogus_late_fees +from public.loans l +join public.loan_emi_schedule s on s.loan_id = l.id +left join public.members m on m.id = l.member_id +left join public.loans_balances lb on lb.loan_id = l.id +where l.repayment_model = 'emi' +group by l.id, l.loan_number, m.name, l.start_date, l.principal_amount, lb.pending_principal +having count(*) filter ( + where s.due_date < (select d from first_legit_due) + and s.status in ('scheduled','overdue') + ) > 0 +order by l.loan_number; + + +-- ---------------------------------------------------------------------------- +-- STEP 2 — Repair one loan. Set :loan_number, run, check the NOTICEs, COMMIT. +-- +-- Replace '202509-001' below with a loan_number from step 1. Do them one at a +-- time so each result can be checked against the loan page before committing. +-- ---------------------------------------------------------------------------- +begin; + +do $$ +declare + v_loan_number text := '202509-001'; -- <<< SET THIS + v_loan record; + v_cutover date; + v_first_legit date; + v_outstanding numeric; + v_rate numeric; + v_settled int; + v_fee_row record; + v_fees_reversed numeric := 0; + v_rows_deleted int; + v_generated int; +begin + select l.*, lb.pending_principal + into v_loan + from public.loans l + left join public.loans_balances lb on lb.loan_id = l.id + where l.loan_number = v_loan_number; + + if v_loan.id is null then + raise exception 'No loan with loan_number %', v_loan_number; + end if; + if v_loan.repayment_model <> 'emi' then + raise exception 'Loan % is on the % model, not emi', v_loan_number, v_loan.repayment_model; + end if; + -- A null term slips past the generator's own `p_term <= 0` guard (null + -- comparisons are never true) and would spin out 1000 null-amount rows. + if v_loan.term_months is null or v_loan.term_months < 1 then + raise exception 'Loan % has no usable term_months (%)', v_loan_number, v_loan.term_months; + end if; + + select to_date(trunc(value)::bigint::text, 'YYYYMMDD') into v_cutover + from public.reference where key = 'emi_cutover_date'; + if v_cutover is null then + raise exception 'emi_cutover_date is not set in public.reference'; + end if; + v_first_legit := (date_trunc('month', v_cutover) + interval '1 month' + interval '9 days')::date; + + -- Guard: never rebuild a schedule that already has settled installments. + select count(*) into v_settled + from public.loan_emi_schedule + where loan_id = v_loan.id + and status in ('paid', 'partially_paid', 'waived'); + if v_settled > 0 then + raise exception + 'Loan % has % settled installment(s); reshape it with Prepay instead of rebuilding', + v_loan_number, v_settled; + end if; + + -- (a) Reverse late fees charged on back-dated rows. The original penalty + -- transaction stays; this posts the balancing negative entry so the pair + -- nets to zero and the reversal is visible in recent activity. + for v_fee_row in + select id, installment_no, late_fee_charged + from public.loan_emi_schedule + where loan_id = v_loan.id + and status in ('scheduled', 'overdue') + and due_date < v_first_legit + and coalesce(late_fee_charged, 0) > 0 + and not coalesce(late_fee_waived, false) + loop + insert into public.transactions + (member_id, loan_id, transaction_type, amount, transaction_date, description) + values + (v_loan.member_id, v_loan.id, 'penalty', -v_fee_row.late_fee_charged, + (now() at time zone 'Asia/Kolkata')::date, + 'Late fee reversed: EMI #' || v_fee_row.installment_no + || ' — installment was back-dated in error'); + v_fees_reversed := v_fees_reversed + v_fee_row.late_fee_charged; + end loop; + + -- (b) Drop the FK from any transaction that points at a row we are deleting + -- (loan_emi_schedule_id is ON DELETE RESTRICT). The transactions + -- themselves are kept — only the link to the bogus installment goes. + update public.transactions t + set loan_emi_schedule_id = null + where t.loan_emi_schedule_id in ( + select id from public.loan_emi_schedule + where loan_id = v_loan.id and status in ('scheduled', 'overdue') + ); + + -- (c) Delete the unsettled schedule. late_fee_txn_id is a plain FK to + -- transactions and does not block the delete. + delete from public.loan_emi_schedule + where loan_id = v_loan.id + and status in ('scheduled', 'overdue'); + get diagnostics v_rows_deleted = row_count; + + -- (d) Rebuild from the cutover on the CURRENT outstanding principal. The + -- generator floors p_start at the cutover itself (migration 051), so + -- passing the loan's own start_date is correct and self-documenting. + v_outstanding := v_loan.pending_principal; + if v_outstanding is null or v_outstanding <= 0 then + raise exception 'Loan % has no outstanding principal (%) to schedule', + v_loan_number, v_outstanding; + end if; + + select value::numeric into v_rate + from public.reference where key = 'loan_interest_rate_pct'; + if v_rate is null then + raise exception 'loan_interest_rate_pct is not set in public.reference'; + end if; + + select public.fn_generate_emi_schedule( + v_loan.id, + v_outstanding, + v_loan.start_date, + v_loan.term_months, + 0, -- waiver is spent; the generator zeroes it when floored anyway + v_rate + ) into v_generated; + + raise notice 'Loan %: deleted % back-dated row(s), reversed % in late fees, generated % installment(s) on an outstanding principal of %', + v_loan_number, v_rows_deleted, v_fees_reversed, v_generated, v_outstanding; +end $$; + +-- Verify before committing: first due date should be the 10th of the month +-- after the cutover, and the opening balance should be the outstanding amount. +select installment_no, due_date, opening_balance, emi_amount, + principal_due, interest_due, closing_balance, status, late_fee_charged + from public.loan_emi_schedule + where loan_id = (select id from public.loans where loan_number = '202509-001') -- <<< SAME LOAN + order by installment_no; + +-- Happy with it? COMMIT; Not happy? ROLLBACK; +commit; + + +-- ---------------------------------------------------------------------------- +-- STEP 3 — Confirm. Re-run STEP 1: it should return zero rows. +-- ---------------------------------------------------------------------------- diff --git a/scripts/prod/migrations/051_emi_schedule_cutover_floor.sql b/scripts/prod/migrations/051_emi_schedule_cutover_floor.sql new file mode 100644 index 0000000..4051aea --- /dev/null +++ b/scripts/prod/migrations/051_emi_schedule_cutover_floor.sql @@ -0,0 +1,198 @@ +-- ============================================================================= +-- 051 — fn_generate_emi_schedule: floor the schedule start at the EMI cutover. +-- +-- THE BUG THIS FIXES +-- A loan converted from the accrual model to EMI has its schedule anchored at +-- `emi_cutover_date` — NOT at loans.start_date (which may be years earlier). +-- convertToEmi got that right, but nothing persisted the anchor, so every +-- LATER regeneration re-derived it from loans.start_date: +-- +-- * updateLoan (src/lib/actions/loans.ts) — fired on ANY edit of an EMI +-- loan, even a notes-only change. +-- * recalculateSchedule (src/lib/actions/emi.ts) — 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 to a back-dated one. +-- Observed in production: a loan showing installment #1 due 2025-10-10 with +-- late fees already charged on months that never should have existed. +-- +-- THE FIX +-- The floor now lives INSIDE the generator, so no caller can bypass it: +-- +-- v_start := greatest(p_start, emi_cutover_date) +-- +-- A loan disbursed before the cutover is scheduled from the cutover; a loan +-- disbursed after it keeps its own start date. When the floor engages, the +-- interest waiver is dropped to 0 — a waiver belongs to the original +-- disbursement and was consumed long before the cutover. +-- +-- Callers still own p_principal. For a converted loan that must be the +-- OUTSTANDING principal, not loans.principal_amount; the accompanying app +-- changes fix the two callers that got that wrong. +-- +-- Everything else is carried over from 044 verbatim: no pre-delete (late fees +-- and 'overdue' markers survive), upsert guarded to unsettled rows, stale tail +-- trimmed. +-- ============================================================================= + +begin; + +create or replace function public.fn_generate_emi_schedule( + p_loan_id uuid, + p_principal numeric, + p_start date, + p_term int, + p_waiver_months int, + p_rate_pct numeric +) +returns int +language plpgsql +security definer +set search_path = public +as $$ +declare + v_cutover date; + v_start date; -- p_start floored at the cutover + v_waiver int; -- p_waiver_months, zeroed when floored + v_r numeric; + v_emi numeric; + v_pow numeric; + v_balance numeric; + v_day int; + v_dim int; + v_has_waiver boolean; + v_make_stub boolean; + v_f numeric; + v_i0 numeric; + v_p0 numeric; + v_inst int := 0; + v_k int; + v_base_off int; + v_off int; + v_due date; + v_interest numeric; + v_principal numeric; + v_emi_amt numeric; + v_is_last boolean; + v_count int := 0; +begin + if p_term <= 0 then + raise exception 'fn_generate_emi_schedule: term must be > 0 (got %)', p_term; + end if; + + -- reference.value is numeric and holds the cutover as a YYYYMMDD integer + -- (20260701 = 2026-07-01). A missing key leaves v_cutover null → no floor, + -- which reproduces the pre-051 behaviour rather than failing the call. + select to_date(trunc(value)::bigint::text, 'YYYYMMDD') + into v_cutover + from public.reference + where key = 'emi_cutover_date'; + + v_start := greatest(p_start, coalesce(v_cutover, p_start)); + -- Floored → this is a pre-cutover loan being scheduled from the cutover. Its + -- original interest waiver is long spent, so it must not shift the schedule. + v_waiver := case when v_start > p_start then 0 else p_waiver_months end; + + v_r := p_rate_pct / 100.0 / 12.0; + + if v_r = 0 then + v_emi := round(p_principal / p_term); + else + v_pow := power(1 + v_r, p_term); + v_emi := round((p_principal * v_r * v_pow) / (v_pow - 1)); + end if; + + -- NO pre-delete: we upsert in place so late_fee_charged/late_fee_txn_id and + -- 'overdue' status on existing rows are preserved. Stale tail is trimmed below. + + v_day := extract(day from v_start)::int; + v_dim := extract(day from (date_trunc('month', v_start) + interval '1 month' - interval '1 day'))::int; + v_has_waiver := v_waiver > 0; + v_make_stub := (not v_has_waiver) and v_day <> 1; + + v_balance := p_principal; + + if v_make_stub then + v_f := least((v_dim - v_day + 1)::numeric / 30.0, 1); + v_i0 := round(p_principal * v_r * v_f); + v_p0 := least(round((v_emi - p_principal * v_r) * v_f), p_principal); + v_inst := 1; + v_due := (date_trunc('month', v_start) + make_interval(months => 1) + interval '9 days')::date; + + insert into public.loan_emi_schedule + (loan_id, installment_no, due_date, opening_balance, emi_amount, + principal_due, interest_due, closing_balance, status) + values + (p_loan_id, v_inst, v_due, p_principal, v_i0 + v_p0, + v_p0, v_i0, p_principal - v_p0, 'scheduled') + on conflict (loan_id, installment_no) do update set + due_date = excluded.due_date, opening_balance = excluded.opening_balance, + emi_amount = excluded.emi_amount, principal_due = excluded.principal_due, + interest_due = excluded.interest_due, closing_balance = excluded.closing_balance, + status = case when public.loan_emi_schedule.status = 'overdue' + then 'overdue' else excluded.status end + where public.loan_emi_schedule.status in ('scheduled', 'overdue'); + + v_count := v_count + 1; + v_balance := p_principal - v_p0; + v_base_off := 2; + else + v_base_off := (case when v_has_waiver then v_waiver else 0 end) + 1; + end if; + + v_k := 0; + while v_balance > 0 and v_k < 1000 loop + v_off := v_base_off + v_k; + v_due := (date_trunc('month', v_start) + make_interval(months => v_off) + interval '9 days')::date; + + v_interest := round(v_balance * v_r); + v_emi_amt := v_emi; + v_principal := v_emi_amt - v_interest; + v_is_last := v_principal >= v_balance; + if v_is_last then + v_principal := v_balance; + v_emi_amt := v_principal + v_interest; + end if; + + v_inst := v_inst + 1; + insert into public.loan_emi_schedule + (loan_id, installment_no, due_date, opening_balance, emi_amount, + principal_due, interest_due, closing_balance, status) + values + (p_loan_id, v_inst, v_due, v_balance, v_emi_amt, + v_principal, v_interest, v_balance - v_principal, 'scheduled') + on conflict (loan_id, installment_no) do update set + due_date = excluded.due_date, opening_balance = excluded.opening_balance, + emi_amount = excluded.emi_amount, principal_due = excluded.principal_due, + interest_due = excluded.interest_due, closing_balance = excluded.closing_balance, + status = case when public.loan_emi_schedule.status = 'overdue' + then 'overdue' else excluded.status end + where public.loan_emi_schedule.status in ('scheduled', 'overdue'); + + v_count := v_count + 1; + v_balance := v_balance - v_principal; + exit when v_is_last; + v_k := v_k + 1; + end loop; + + -- Trim a stale tail (a previously-longer schedule), but never settled rows. + delete from public.loan_emi_schedule + where loan_id = p_loan_id + and installment_no > v_inst + and status in ('scheduled', 'overdue'); + + update public.loans + set repayment_model = 'emi', + term_months = p_term, + interest_rate_pct = p_rate_pct, + emi_amount = v_emi, + schedule_generated_at = now() + where id = p_loan_id; + + return v_count; +end; +$$; + +commit; + +notify pgrst, 'reload schema'; diff --git a/src/lib/actions/emi.ts b/src/lib/actions/emi.ts index 3cfb587..f2dd8fc 100644 --- a/src/lib/actions/emi.ts +++ b/src/lib/actions/emi.ts @@ -5,7 +5,8 @@ import { createClient } from '@/lib/supabase/server' import { getCurrentUser } from './auth' import { getReference, applyBalanceDelta } from './reference' import { actionError, actionOk, runAction, type ActionResult } from './action-result' -import { recomputeAfterPrepayment } from '@/lib/emi-math' +import { recomputeAfterPrepayment, tenthOfMonth } from '@/lib/emi-math' +import { cutoverYmdToIso, isCutoverFloored } from '@/lib/emi-anchor' export type EmiScheduleRow = { id: string @@ -252,14 +253,41 @@ export async function prepayLoan(formData: FormData): Promise { .select('id', { count: 'exact', head: true }) .eq('loan_id', loanId) .in('status', ['scheduled', 'overdue']) + + // The rebuilt tail starts on the 10th of the month AFTER the prepayment + // date — never at `next_due_date`, which is the earliest UNPAID due date + // and can sit in the past (a genuinely missed month, or a schedule that a + // bad regeneration back-dated; see migration 051). Anchoring there + // regenerated the tail into the past and compounded the damage on every + // subsequent prepayment. Any unpaid earlier installments are dropped + // below and their principal re-amortizes across this new tail — it is + // already inside `newOutstanding`, so nothing is written off. + const firstDueDate = tenthOfMonth(paidDate, 1) + const rows = recomputeAfterPrepayment({ outstanding: newOutstanding, annualRatePct: Number(bal.interest_rate_pct), remainingTerm: count ?? 1, currentEmi: Number(bal.emi_amount), - firstDueDate: String(bal.next_due_date), + firstDueDate, mode, }) + + // Late fees already charged on the rows we are about to drop are real + // receivables — each has a matching penalty transaction. Carry the + // unwaived total onto the first row of the new tail so it stays + // collectable; without this the delete below silently forgave it. + const { data: feeRows, error: feeErr } = await supabase + .from('loan_emi_schedule') + .select('late_fee_charged, late_fee_waived') + .eq('loan_id', loanId) + .in('status', ['scheduled', 'overdue']) + if (feeErr) return actionError(feeErr.message) + const carriedLateFee = (feeRows ?? []).reduce( + (sum, r) => (r.late_fee_waived ? sum : sum + (Number(r.late_fee_charged) || 0)), + 0, + ) + // Replace unpaid rows with the recomputed schedule (delete + reinsert). const { error: delErr } = await supabase .from('loan_emi_schedule') @@ -277,7 +305,7 @@ export async function prepayLoan(formData: FormData): Promise { .limit(1) .maybeSingle() let n = maxRow?.installment_no ?? 0 - const insertRows = rows.map((r) => ({ + const insertRows = rows.map((r, idx) => ({ loan_id: loanId, installment_no: ++n, due_date: r.dueDate, @@ -286,6 +314,11 @@ export async function prepayLoan(formData: FormData): Promise { principal_due: r.principalDue, interest_due: r.interestDue, closing_balance: r.closingBalance, + // Outstanding fees from the dropped rows ride on the first new + // installment. `late_fee_txn_id` stays null — it is a single FK and the + // carried total may span several penalty transactions, which remain in + // `transactions` as the audit trail. + late_fee_charged: idx === 0 ? carriedLateFee : 0, })) if (insertRows.length > 0) { const { error } = await supabase.from('loan_emi_schedule').insert(insertRows) @@ -327,9 +360,29 @@ export async function recalculateSchedule(formData: FormData): Promise 8) + + // A loan that predates the cutover was CONVERTED to EMI, so its schedule + // amortizes what is still outstanding — passing principal_amount here was + // the second half of the back-dating incident (migration 051): it rebuilt + // the schedule against the full amount originally lent, ignoring every + // repayment made under the accrual model. The generator floors the START + // date itself; only the principal is the caller's job. + const cutoverIso = cutoverYmdToIso(await getReference('emi_cutover_date').catch(() => 0)) + let principal = Number(loan.principal_amount) + if (isCutoverFloored(loan.start_date, cutoverIso)) { + const { data: lb } = await supabase + .from('loans_balances') + .select('pending_principal') + .eq('loan_id', loanId) + .single() + if (!lb) return actionError('Cannot read outstanding principal for this loan') + principal = Number(lb.pending_principal) + } + if (!(principal > 0)) return actionError('Loan has no outstanding principal to schedule') + const { error } = await supabase.rpc('fn_generate_emi_schedule', { p_loan_id: loanId, - p_principal: loan.principal_amount, + p_principal: principal, p_start: loan.start_date, p_term: loan.term_months, p_waiver_months: loan.interest_waiver_months, @@ -369,8 +422,8 @@ export async function convertToEmi(formData: FormData): Promise { .single() if (!lb) return actionError('Loan not found') // emi_cutover_date is stored as a YYYYMMDD integer (reference.value is numeric). - const cutoverYmd = await getReference('emi_cutover_date') - const cutover = `${String(cutoverYmd).slice(0, 4)}-${String(cutoverYmd).slice(4, 6)}-${String(cutoverYmd).slice(6, 8)}` + const cutover = cutoverYmdToIso(await getReference('emi_cutover_date')) + if (!cutover) return actionError('emi_cutover_date is not configured') const ratePct = await getReference('loan_interest_rate_pct').catch(() => 8) // NOTE (spec §10): legacy accrued interest is PRESERVED — do NOT waive or roll it. diff --git a/src/lib/actions/loans.ts b/src/lib/actions/loans.ts index a219be1..366729c 100644 --- a/src/lib/actions/loans.ts +++ b/src/lib/actions/loans.ts @@ -9,6 +9,7 @@ import { type BalanceDirection, } from '@/lib/balance-direction' import { computeLoanFinancials, type LoanFinancials } from '@/lib/loan-math' +import { cutoverYmdToIso, isCutoverFloored } from '@/lib/emi-anchor' import { actionError, actionOk, @@ -713,6 +714,16 @@ export async function updateLoan(formData: FormData): Promise { patch.poll_id = pollIdRaw === '' ? null : pollIdRaw } + // Snapshot the schedule-shaping fields before the patch lands, so the + // regeneration guard below can tell an actual reshape from a notes-only + // edit (which must never touch the schedule). + const { data: before, error: beforeErr } = await supabase + .from('loans') + .select('principal_amount, start_date, interest_waiver_months, term_months') + .eq('id', loanId) + .single() + if (beforeErr || !before) return actionError(beforeErr?.message ?? 'Loan not found') + const { error } = await supabase.from('loans').update(patch).eq('id', loanId) if (error) { if (isPollAlreadyLinkedError(error)) { @@ -724,9 +735,20 @@ export async function updateLoan(formData: FormData): Promise { return actionError(error.message) } - // Regenerate the EMI schedule for EMI loans so principal/start/waiver/term - // edits take effect. The generator preserves paid/partially_paid/waived - // rows, so re-running is safe. Accrual-model loans are left untouched. + // Regenerate the EMI schedule ONLY when an edit actually reshapes it. + // + // This block used to run on EVERY edit of an EMI loan — including a + // notes-only change — rebuilding from `principal_amount` and `start_date`. + // For a loan CONVERTED from the accrual model both are wrong: its schedule + // is anchored at the EMI cutover and covers the principal still + // outstanding, not the original disbursement. Because the generator upserts + // in place (migration 044), each such call rewrote every unsettled row's + // due date to a back-dated one. See migration 051. + // + // Now: the generator floors the start date at the cutover itself, this only + // fires when a shaping field changed, and it refuses to run once any + // installment is settled (reshaping a part-repaid schedule is what + // prepayment is for). const { data: updatedLoan, error: fetchErr } = await supabase .from('loans') .select('repayment_model, principal_amount, start_date, interest_waiver_months, term_months') @@ -739,17 +761,56 @@ export async function updateLoan(formData: FormData): Promise { termRaw == null || String(termRaw).trim() === '' ? Number(updatedLoan.term_months) : Number(termRaw) - if (Number.isInteger(termMonths) && termMonths >= 1) { - const ratePct = await getReference('loan_interest_rate_pct').then(Number).catch(() => 8) - const { error: schedErr } = await supabase.rpc('fn_generate_emi_schedule', { - p_loan_id: loanId, - p_principal: Number(updatedLoan.principal_amount), - p_start: updatedLoan.start_date, - p_term: termMonths, - p_waiver_months: Number(updatedLoan.interest_waiver_months) || 0, - p_rate_pct: ratePct, - }) - if (schedErr) return actionError(schedErr.message) + + const shapeChanged = + (principalRaw !== '' && principal !== Number(before.principal_amount)) || + (startDate != null && startDate !== before.start_date) || + (waiverMonthsInput != null && + waiverMonthsInput !== Number(before.interest_waiver_months ?? 0)) || + (Number.isInteger(termMonths) && termMonths !== Number(before.term_months)) + + if (shapeChanged && Number.isInteger(termMonths) && termMonths >= 1) { + const { count: settledCount } = await supabase + .from('loan_emi_schedule') + .select('id', { count: 'exact', head: true }) + .eq('loan_id', loanId) + .in('status', ['paid', 'partially_paid', 'waived']) + if ((settledCount ?? 0) > 0) { + return actionError( + 'This loan has settled EMI installments — editing principal, start date, waiver or term would rebuild the schedule underneath them. Use Prepay on the loan page instead.', + 'principal_amount', + ) + } + + // A pre-cutover loan is a converted one: schedule the OUTSTANDING + // principal, not the amount originally lent. The generator floors the + // start date, so `start_date` is safe to pass through as-is. + const cutoverIso = cutoverYmdToIso( + await getReference('emi_cutover_date').then(Number).catch(() => 0), + ) + let schedulePrincipal = Number(updatedLoan.principal_amount) + if (isCutoverFloored(updatedLoan.start_date, cutoverIso)) { + const { data: lb } = await supabase + .from('loans_balances') + .select('pending_principal') + .eq('loan_id', loanId) + .single() + if (!lb) return actionError('Cannot read outstanding principal for this loan') + schedulePrincipal = Number(lb.pending_principal) + } + + if (schedulePrincipal > 0) { + const ratePct = await getReference('loan_interest_rate_pct').then(Number).catch(() => 8) + const { error: schedErr } = await supabase.rpc('fn_generate_emi_schedule', { + p_loan_id: loanId, + p_principal: schedulePrincipal, + p_start: updatedLoan.start_date, + p_term: termMonths, + p_waiver_months: Number(updatedLoan.interest_waiver_months) || 0, + p_rate_pct: ratePct, + }) + if (schedErr) return actionError(schedErr.message) + } } } diff --git a/src/lib/emi-anchor.test.ts b/src/lib/emi-anchor.test.ts new file mode 100644 index 0000000..840eff2 --- /dev/null +++ b/src/lib/emi-anchor.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { cutoverYmdToIso, emiScheduleStart, isCutoverFloored } from './emi-anchor' + +const CUTOVER = '2026-07-01' + +describe('cutoverYmdToIso', () => { + it('converts the YYYYMMDD integer held in reference.value', () => { + expect(cutoverYmdToIso(20260701)).toBe('2026-07-01') + }) + + it('tolerates a numeric value that arrived with a fractional part', () => { + expect(cutoverYmdToIso(20260701.0)).toBe('2026-07-01') + }) + + it('returns null for an unset / malformed value rather than a bogus date', () => { + expect(cutoverYmdToIso(0)).toBeNull() + expect(cutoverYmdToIso(202607)).toBeNull() + }) +}) + +describe('emiScheduleStart', () => { + it('floors a pre-cutover loan at the cutover', () => { + // The production incident: a 2025 loan was scheduled from its own start + // date, producing installments due 2025-10-10 onward. + expect(emiScheduleStart('2025-09-15', CUTOVER)).toBe(CUTOVER) + }) + + it('leaves a post-cutover loan on its own start date', () => { + expect(emiScheduleStart('2026-09-15', CUTOVER)).toBe('2026-09-15') + }) + + it('is a no-op for a loan starting exactly on the cutover', () => { + expect(emiScheduleStart(CUTOVER, CUTOVER)).toBe(CUTOVER) + }) + + it('applies no floor when the cutover is unconfigured', () => { + expect(emiScheduleStart('2025-09-15', null)).toBe('2025-09-15') + }) +}) + +describe('isCutoverFloored', () => { + it('is true for a converted (pre-cutover) loan', () => { + expect(isCutoverFloored('2025-09-15', CUTOVER)).toBe(true) + }) + + it('is false for a natively-EMI loan created after the cutover', () => { + expect(isCutoverFloored('2026-09-15', CUTOVER)).toBe(false) + }) + + it('is false on the cutover date itself', () => { + expect(isCutoverFloored(CUTOVER, CUTOVER)).toBe(false) + }) + + it('is false when the cutover is unconfigured', () => { + expect(isCutoverFloored('2025-09-15', null)).toBe(false) + }) +}) diff --git a/src/lib/emi-anchor.ts b/src/lib/emi-anchor.ts new file mode 100644 index 0000000..97df4a5 --- /dev/null +++ b/src/lib/emi-anchor.ts @@ -0,0 +1,44 @@ +/** + * Where an EMI schedule starts, and what principal it amortizes. + * + * A loan converted from the accrual model to EMI is scheduled from the EMI + * cutover, not from its original disbursement — and it amortizes the principal + * still OUTSTANDING at conversion, not the amount originally lent. Neither + * value is stored on the loan, so any code that regenerates a schedule has to + * re-derive both. Getting it wrong back-dates the whole schedule (see migration + * 051 for the production incident). + * + * The date rule is mirrored in SQL inside `fn_generate_emi_schedule`, which is + * the real enforcement point; these helpers keep the app's own decisions (which + * principal to pass, whether a regeneration is even safe) consistent with it. + */ + +/** `emi_cutover_date` is stored in `reference` as a YYYYMMDD integer. */ +export function cutoverYmdToIso(ymd: number): string | null { + const s = String(Math.trunc(ymd)) + if (!/^\d{8}$/.test(s)) return null + return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` +} + +/** + * The date an EMI schedule is anchored at: the later of the loan's own start + * date and the cutover. Mirrors `greatest(p_start, emi_cutover_date)` in + * migration 051. A null/blank cutover means no floor. + */ +export function emiScheduleStart(startDateIso: string, cutoverIso: string | null): string { + if (!cutoverIso) return startDateIso + return startDateIso > cutoverIso ? startDateIso : cutoverIso +} + +/** + * True when the cutover floor engages — i.e. the loan predates the cutover and + * was therefore CONVERTED to EMI rather than created on it. + * + * This is the same test that decides which principal to amortize: a converted + * loan's schedule covers what is still outstanding, while a natively-EMI loan's + * covers the full amount lent. + */ +export function isCutoverFloored(startDateIso: string, cutoverIso: string | null): boolean { + if (!cutoverIso) return false + return startDateIso < cutoverIso +} diff --git a/src/lib/emi-math.test.ts b/src/lib/emi-math.test.ts index ac19c42..f0a20a2 100644 --- a/src/lib/emi-math.test.ts +++ b/src/lib/emi-math.test.ts @@ -138,4 +138,27 @@ describe('recomputeAfterPrepayment', () => { expect(r).toHaveLength(1) expect(r[0].closingBalance).toBe(0) }) + + // prepayLoan passes `tenthOfMonth(paidDate, 1)` as firstDueDate so the tail + // always starts on the 10th of the month AFTER the payment — it used to pass + // `next_due_date`, which could be in the past and regenerated the schedule + // backwards. These pin the anchor the action relies on. + it('anchors the tail on the 10th of the month after the prepayment date', () => { + expect(tenthOfMonth('2026-08-06', 1)).toBe('2026-09-10') + expect(tenthOfMonth('2026-08-20', 1)).toBe('2026-09-10') + }) + + it('never emits a past-dated installment when anchored off the payment date', () => { + const paidDate = '2026-08-06' + const r = recomputeAfterPrepayment({ + outstanding: 50000, annualRatePct: 8, remainingTerm: 10, + currentEmi: 5914, firstDueDate: tenthOfMonth(paidDate, 1), mode: 'reduce_tenure', + }) + expect(r[0].dueDate).toBe('2026-09-10') + for (const row of r) expect(row.dueDate > paidDate).toBe(true) + }) + + it('rolls into the next year when the prepayment lands in December', () => { + expect(tenthOfMonth('2026-12-28', 1)).toBe('2027-01-10') + }) }) From 535f98768bb84f122025d3c993f0b4fd0251e153 Mon Sep 17 00:00:00 2001 From: Paramesh Korrakuti Date: Thu, 6 Aug 2026 22:09:30 +0530 Subject: [PATCH 3/3] fix(scripts): single edit point for the EMI repair target 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) --- scripts/prod/fix-emi-backdated-schedules.sql | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/prod/fix-emi-backdated-schedules.sql b/scripts/prod/fix-emi-backdated-schedules.sql index ab0e996..eff5c79 100644 --- a/scripts/prod/fix-emi-backdated-schedules.sql +++ b/scripts/prod/fix-emi-backdated-schedules.sql @@ -88,16 +88,22 @@ order by l.loan_number; -- ---------------------------------------------------------------------------- --- STEP 2 — Repair one loan. Set :loan_number, run, check the NOTICEs, COMMIT. +-- STEP 2 — Repair one loan. Set the loan number ONCE below, run the whole +-- block, check the NOTICE, then COMMIT (or ROLLBACK). -- --- Replace '202509-001' below with a loan_number from step 1. Do them one at a --- time so each result can be checked against the loan page before committing. +-- Do the loans one at a time so each result can be checked against the loan +-- page before committing. -- ---------------------------------------------------------------------------- begin; +-- >>> THE ONLY LINE TO EDIT. Both the repair and the verification query below +-- read the target from here, so there is no second copy to forget. +create temp table _repair_target on commit drop as + select '202503-003'::text as loan_number; + do $$ declare - v_loan_number text := '202509-001'; -- <<< SET THIS + v_loan_number text := (select loan_number from _repair_target); v_loan record; v_cutover date; v_first_legit date; @@ -217,7 +223,10 @@ end $$; select installment_no, due_date, opening_balance, emi_amount, principal_due, interest_due, closing_balance, status, late_fee_charged from public.loan_emi_schedule - where loan_id = (select id from public.loans where loan_number = '202509-001') -- <<< SAME LOAN + where loan_id = ( + select id from public.loans + where loan_number = (select loan_number from _repair_target) + ) order by installment_no; -- Happy with it? COMMIT; Not happy? ROLLBACK;