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() {
+ Permanently removes {transactionId}. This action
+ cannot be undone.
+ {state.error}