diff --git a/scripts/prod/fix-emi-backdated-schedules.sql b/scripts/prod/fix-emi-backdated-schedules.sql new file mode 100644 index 0000000..eff5c79 --- /dev/null +++ b/scripts/prod/fix-emi-backdated-schedules.sql @@ -0,0 +1,238 @@ +-- ============================================================================= +-- 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 the loan number ONCE below, run the whole +-- block, check the NOTICE, then COMMIT (or ROLLBACK). +-- +-- 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 := (select loan_number from _repair_target); + 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 = (select loan_number from _repair_target) + ) + 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/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} 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') + }) })