diff --git a/scripts/prod/fix-emi-cutover-duplicate-accruals.sql b/scripts/prod/fix-emi-cutover-duplicate-accruals.sql new file mode 100644 index 0000000..82e2c74 --- /dev/null +++ b/scripts/prod/fix-emi-cutover-duplicate-accruals.sql @@ -0,0 +1,116 @@ +-- ============================================================================= +-- FCF Tracker — one-off fix: drop the July 2026 accrual on EMI loans +-- +-- Problem +-- A loan converted to the EMI model is charged interest twice for July 2026: +-- * public.loan_interest_accruals — the EOM cron wrote a period_end +-- 2026-07-31 row while the loan was still on the `accrual` model. +-- * public.loan_emi_schedule — installment #1 is due 2026-08-10, and +-- under the 041 model an installment due on the 10th carries the +-- interest for the PREVIOUS month, i.e. July. +-- Only one should exist. The EMI schedule wins, so the 2026-07-31 accrual +-- rows go. +-- +-- Scope — deliberately narrow +-- EMI loans only (`loans.repayment_model = 'emi'`), and ONLY the single +-- period_end = 2026-07-31 row. Every other accrual (including any later +-- month, should one exist) is left untouched — per spec §10 the member keeps +-- paying legacy accrued interest month by month via payLoanInterest. +-- +-- Safety +-- * Rows with any settlement (paid_amount > 0, or a loan_interest_payments +-- junction row) are excluded. loan_interest_payments.accrual_id is +-- ON DELETE RESTRICT, so a paid row would abort the statement anyway — +-- the guard makes the intent explicit instead of relying on the FK error. +-- * Run step 1 first and eyeball the rows. Step 2 is wrapped in an explicit +-- transaction: check the reported row count, then COMMIT (or ROLLBACK). +-- * Re-running is harmless — the second pass matches nothing. +-- +-- Run as the Supabase SQL editor's default role (owner; bypasses RLS). +-- The going-forward cron is already correct: fn_compute_loan_interest_for +-- skips repayment_model = 'emi' (migration 039, patch E). This only cleans up +-- rows written BEFORE each loan was converted. +-- ============================================================================= + + +-- ----------------------------------------------------------------------------- +-- Step 1 — PREVIEW. Nothing is modified. Confirm this is the expected set. +-- ----------------------------------------------------------------------------- +select + l.loan_number, + m.name as member, + a.period_end, + a.amount_due, + a.paid_amount, + a.status, + -- The EMI installment that already covers July (due 10 Aug 2026). + s.due_date as covered_by_emi_due, + s.interest_due as emi_interest_component +from public.loan_interest_accruals a +join public.loans l on l.id = a.loan_id +left join public.members m on m.id = l.member_id +left join public.loan_emi_schedule s + on s.loan_id = a.loan_id + and s.due_date = date '2026-08-10' +where l.repayment_model = 'emi' + and a.period_end = date '2026-07-31' + and a.paid_amount = 0 + and not exists ( + select 1 from public.loan_interest_payments p where p.accrual_id = a.id + ) +order by l.loan_number; + + +-- ----------------------------------------------------------------------------- +-- Step 2 — DELETE. Review the row count, then COMMIT. +-- ----------------------------------------------------------------------------- +begin; + +delete from public.loan_interest_accruals a +using public.loans l +where l.id = a.loan_id + and l.repayment_model = 'emi' + and a.period_end = date '2026-07-31' + and a.paid_amount = 0 + and not exists ( + select 1 from public.loan_interest_payments p where p.accrual_id = a.id + ); + +-- Expect exactly one row per converted EMI loan. +-- If the count looks wrong: ROLLBACK; +commit; + + +-- ----------------------------------------------------------------------------- +-- Alternative to step 2 — WAIVE instead of DELETE. +-- +-- Keeps the row for audit (the loan-detail accrual timeline still shows July) +-- while zeroing it out of loans_balances.pending_interest. Mirrors what +-- fn_waive_accruals_on_loan_close does at closure. Use this INSTEAD OF step 2, +-- not in addition to it. +-- ----------------------------------------------------------------------------- +-- begin; +-- update public.loan_interest_accruals a +-- set status = 'waived', +-- amount_due = 0, +-- waiver_reason = 'emi_conversion', +-- recomputed_at = now() +-- from public.loans l +-- where l.id = a.loan_id +-- and l.repayment_model = 'emi' +-- and a.period_end = date '2026-07-31' +-- and a.status = 'pending' +-- and a.paid_amount = 0; +-- commit; + + +-- ----------------------------------------------------------------------------- +-- Step 3 — VERIFY. Should return zero rows. +-- ----------------------------------------------------------------------------- +select l.loan_number, a.period_end, a.amount_due, a.status +from public.loan_interest_accruals a +join public.loans l on l.id = a.loan_id +where l.repayment_model = 'emi' + and a.period_end = date '2026-07-31' + and a.status <> 'waived' +order by l.loan_number; diff --git a/src/app/(app)/admin/loans/page.tsx b/src/app/(app)/admin/loans/page.tsx index b8436ea..9b2179e 100644 --- a/src/app/(app)/admin/loans/page.tsx +++ b/src/app/(app)/admin/loans/page.tsx @@ -5,6 +5,7 @@ import { getLoans, getInterestPerLakh } from '@/lib/actions/loans' import { LoansListTable, type LoansListRow } from '@/components/loans-list-table' import { LoansTabs, type LoansTabKey } from '@/components/loans-tabs' import { computeLoanFinancials, type LoanTxnInput } from '@/lib/loan-math' +import { UNPAID_EMI_STATUSES } from '@/lib/constants' export default async function AdminLoansListPage({ searchParams, @@ -37,7 +38,7 @@ export default async function AdminLoansListPage({ past_due_count: number | null oldest_past_due_date: string | null } - const [{ data: txnsRaw }, { data: emiBalRaw }] = await Promise.all([ + const [{ data: txnsRaw }, { data: emiBalRaw }, { data: emiPendingRaw }] = await Promise.all([ loanIds.length ? supabase .from('transactions') @@ -50,6 +51,16 @@ export default async function AdminLoansListPage({ .select('loan_id, next_due_date, past_due_count, oldest_past_due_date') .in('loan_id', emiLoanIds) : Promise.resolve({ data: [] as EmiBalRow[] }), + // Unpaid installments left on each schedule. `loan_emi_balances` exposes + // overdue/past-due counts but not the remaining-term count, so tally the + // schedule rows directly. + emiLoanIds.length + ? supabase + .from('loan_emi_schedule') + .select('loan_id') + .in('loan_id', emiLoanIds) + .in('status', UNPAID_EMI_STATUSES) + : Promise.resolve({ data: [] as { loan_id: string }[] }), ]) const nextDueByLoan = new Map() @@ -62,6 +73,11 @@ export default async function AdminLoansListPage({ }) } + const pendingEmiByLoan = new Map() + for (const r of (emiPendingRaw ?? []) as { loan_id: string }[]) { + pendingEmiByLoan.set(r.loan_id, (pendingEmiByLoan.get(r.loan_id) ?? 0) + 1) + } + type TxnAgg = LoanTxnInput & { loan_id: string } const txns = (txnsRaw ?? []) as TxnAgg[] @@ -90,6 +106,8 @@ export default async function AdminLoansListPage({ oldest_overdue_date: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.oldest ?? null : null, emi_amount: l.repayment_model === 'emi' ? l.emi_amount : null, + pending_emi_count: + l.repayment_model === 'emi' ? pendingEmiByLoan.get(l.id) ?? 0 : null, balance: f.balance, detail_href: `/admin/loans/${encodeURIComponent(l.loan_number)}`, } diff --git a/src/app/(app)/dashboard/loans/page.tsx b/src/app/(app)/dashboard/loans/page.tsx index dfbcd1d..514bcce 100644 --- a/src/app/(app)/dashboard/loans/page.tsx +++ b/src/app/(app)/dashboard/loans/page.tsx @@ -4,6 +4,7 @@ import { getLoans, getInterestPerLakh } from '@/lib/actions/loans' import { LoansListTable, type LoansListRow } from '@/components/loans-list-table' import { LoansTabs, type LoansTabKey } from '@/components/loans-tabs' import { computeLoanFinancials, type LoanTxnInput } from '@/lib/loan-math' +import { UNPAID_EMI_STATUSES } from '@/lib/constants' import { RefreshButton } from '@/components/ui/refresh-button' import { LoansFilters } from './loans-filters' @@ -45,7 +46,7 @@ export default async function LoansListPage({ const loanIds = loans.map((l) => l.id) const emiLoanIds = loans.filter((l) => l.repayment_model === 'emi').map((l) => l.id) - const [{ data: txnsRaw }, { data: emiBalRaw }] = await Promise.all([ + const [{ data: txnsRaw }, { data: emiBalRaw }, { data: emiPendingRaw }] = await Promise.all([ loanIds.length ? supabase .from('transactions') @@ -58,6 +59,16 @@ export default async function LoansListPage({ .select('loan_id, next_due_date, past_due_count, oldest_past_due_date') .in('loan_id', emiLoanIds) : Promise.resolve({ data: [] as EmiBalRow[] }), + // Unpaid installments left on each schedule. `loan_emi_balances` exposes + // overdue/past-due counts but not the remaining-term count, so tally the + // schedule rows directly. + emiLoanIds.length + ? supabase + .from('loan_emi_schedule') + .select('loan_id') + .in('loan_id', emiLoanIds) + .in('status', UNPAID_EMI_STATUSES) + : Promise.resolve({ data: [] as { loan_id: string }[] }), ]) const nextDueByLoan = new Map() @@ -70,6 +81,11 @@ export default async function LoansListPage({ }) } + const pendingEmiByLoan = new Map() + for (const r of (emiPendingRaw ?? []) as { loan_id: string }[]) { + pendingEmiByLoan.set(r.loan_id, (pendingEmiByLoan.get(r.loan_id) ?? 0) + 1) + } + type TxnAgg = LoanTxnInput & { loan_id: string } const txns = (txnsRaw ?? []) as TxnAgg[] @@ -98,6 +114,8 @@ export default async function LoansListPage({ oldest_overdue_date: l.repayment_model === 'emi' ? overdueByLoan.get(l.id)?.oldest ?? null : null, emi_amount: l.repayment_model === 'emi' ? l.emi_amount : null, + pending_emi_count: + l.repayment_model === 'emi' ? pendingEmiByLoan.get(l.id) ?? 0 : null, balance: f.balance, detail_href: `/dashboard/loans/${encodeURIComponent(l.loan_number)}`, } diff --git a/src/components/loans-list-table.tsx b/src/components/loans-list-table.tsx index 48b323d..9a7d175 100644 --- a/src/components/loans-list-table.tsx +++ b/src/components/loans-list-table.tsx @@ -31,6 +31,9 @@ export type LoansListRow = { oldest_overdue_date?: string | null /** Monthly installment for EMI loans; null for accrual-model loans. */ emi_amount?: number | null + /** Unpaid installments left on the schedule (scheduled + partially paid + + * overdue). Null for accrual-model loans. */ + pending_emi_count?: number | null balance: number detail_href: string } @@ -41,6 +44,7 @@ type LoansListRowAug = LoansListRow & { _start_ts: number _end_ts: number _emi: number + _pending_emi: number _type_label: string _search_blob: string } @@ -94,6 +98,7 @@ export function LoansListTable({ _start_ts: new Date(l.start_date).getTime(), _end_ts: l.end_date ? new Date(l.end_date).getTime() : 0, _emi: emi, + _pending_emi: Number(l.pending_emi_count ?? 0), _type_label: typeLabel, _search_blob: [ l.loan_number, @@ -120,7 +125,7 @@ export function LoansListTable({ // --- Export (reflects the current filter + sort) ------------------------- const exportColumns = [ - 'Loan #', 'Member', 'Type', 'Principal (₹)', 'EMI (₹)', 'Start date', + 'Loan #', 'Member', 'Type', 'Principal (₹)', 'EMI (₹)', 'Pending EMI', 'Start date', ...(showEndDate ? ['End date'] : []), 'Outstanding (₹)', ] @@ -130,6 +135,7 @@ export function LoansListTable({ l._type_label, l.principal_amount, l._emi > 0 ? l._emi : '', + l.repayment_model === 'emi' ? l._pending_emi : '', formatDate(l.start_date), ...(showEndDate ? [formatDate(l.end_date ?? null)] : []), l.balance, @@ -305,6 +311,21 @@ export function LoansListTable({ body: (l) => l._emi > 0 ? formatRupees(l._emi) : , }, + { + field: '_pending_emi', + header: 'Pending EMI', + sortable: true, + align: 'right', + dataType: 'numeric', + bodyClassName: 'whitespace-nowrap px-3 py-2.5 text-right tabular-nums text-gray-700', + // A plain count of unpaid installments — not a rupee value. + body: (l) => + l.repayment_model === 'emi' ? ( + l._pending_emi + ) : ( + + ), + }, { field: '_start_ts', header: 'Start date', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 14ab160..65b2a65 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -15,3 +15,7 @@ export type PaymentStatus = (typeof PAYMENT_STATUS)[number] export const USER_ROLES = ['admin', 'user'] as const export type UserRole = (typeof USER_ROLES)[number] + +/** Installment statuses that still owe money — the "pending EMI" set. Mirrors + * the filter `loan_emi_balances` uses for pending_interest / next_due_date. */ +export const UNPAID_EMI_STATUSES = ['scheduled', 'partially_paid', 'overdue'] as const