Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions scripts/prod/fix-emi-cutover-duplicate-accruals.sql
Original file line number Diff line number Diff line change
@@ -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;
20 changes: 19 additions & 1 deletion src/app/(app)/admin/loans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand All @@ -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<string, string | null>()
Expand All @@ -62,6 +73,11 @@ export default async function AdminLoansListPage({
})
}

const pendingEmiByLoan = new Map<string, number>()
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[]

Expand Down Expand Up @@ -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)}`,
}
Expand Down
20 changes: 19 additions & 1 deletion src/app/(app)/dashboard/loans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand All @@ -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<string, string | null>()
Expand All @@ -70,6 +81,11 @@ export default async function LoansListPage({
})
}

const pendingEmiByLoan = new Map<string, number>()
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[]

Expand Down Expand Up @@ -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)}`,
}
Expand Down
23 changes: 22 additions & 1 deletion src/components/loans-list-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -41,6 +44,7 @@ type LoansListRowAug = LoansListRow & {
_start_ts: number
_end_ts: number
_emi: number
_pending_emi: number
_type_label: string
_search_blob: string
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 (₹)',
]
Expand All @@ -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,
Expand Down Expand Up @@ -305,6 +311,21 @@ export function LoansListTable({
body: (l) =>
l._emi > 0 ? formatRupees(l._emi) : <span className="text-gray-400">—</span>,
},
{
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
) : (
<span className="text-gray-400">—</span>
),
},
{
field: '_start_ts',
header: 'Start date',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading