From 4da8d6a0e3e3f3917ac3d2b8d854f4c50d613509 Mon Sep 17 00:00:00 2001 From: Paramesh Korrakuti Date: Thu, 6 Aug 2026 20:40:47 +0530 Subject: [PATCH] fix(loans): Pending EMI counts installments due as of this month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column was showing the whole remaining term (12 for a freshly converted loan). It now counts unpaid installments due on or before the current month's end (IST) — carry-over from earlier months plus this month's — so it answers "how many EMIs does this member owe right now". Month-end rather than today's date is the cut-off, so an installment due on the 10th counts from the 1st instead of reading 0 for the first nine days of every month. - New endOfMonth() helper in lib/due.ts, with tests. - Both list pages derive todayIso once (it was computed twice) and pass the month-end cut-off to the count query. - Counts above 1 render amber with a tooltip, so a member falling behind is visible at a glance. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/(app)/admin/loans/page.tsx | 12 ++++++++---- src/app/(app)/dashboard/loans/page.tsx | 12 ++++++++---- src/components/loans-list-table.tsx | 24 ++++++++++++++++++------ src/lib/due.test.ts | 20 +++++++++++++++++++- src/lib/due.ts | 12 ++++++++++++ 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/app/(app)/admin/loans/page.tsx b/src/app/(app)/admin/loans/page.tsx index 9b2179e..68ccad6 100644 --- a/src/app/(app)/admin/loans/page.tsx +++ b/src/app/(app)/admin/loans/page.tsx @@ -6,6 +6,7 @@ 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 { endOfMonth } from '@/lib/due' export default async function AdminLoansListPage({ searchParams, @@ -32,6 +33,8 @@ export default async function AdminLoansListPage({ const loanIds = loans.map((l) => l.id) const emiLoanIds = loans.filter((l) => l.repayment_model === 'emi').map((l) => l.id) + const todayIso = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kolkata' }).format(new Date()) + const currentMonthEnd = endOfMonth(todayIso) type EmiBalRow = { loan_id: string next_due_date: string | null @@ -51,15 +54,17 @@ 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. + // Installments payable as of this month: unpaid rows due on or before the + // current month's end. Counts anything carried over from earlier months + // plus this month's installment, whether or not its 10th has passed — + // NOT the whole remaining term. emiLoanIds.length ? supabase .from('loan_emi_schedule') .select('loan_id') .in('loan_id', emiLoanIds) .in('status', UNPAID_EMI_STATUSES) + .lte('due_date', currentMonthEnd) : Promise.resolve({ data: [] as { loan_id: string }[] }), ]) @@ -115,7 +120,6 @@ export default async function AdminLoansListPage({ const activeRows = tableRows.filter((r) => r.status === 'active') const pastRows = tableRows.filter((r) => r.status !== 'active') - const todayIso = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kolkata' }).format(new Date()) const emptyMessage = ( <> diff --git a/src/app/(app)/dashboard/loans/page.tsx b/src/app/(app)/dashboard/loans/page.tsx index 514bcce..ec3ecc1 100644 --- a/src/app/(app)/dashboard/loans/page.tsx +++ b/src/app/(app)/dashboard/loans/page.tsx @@ -5,6 +5,7 @@ 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 { endOfMonth } from '@/lib/due' import { RefreshButton } from '@/components/ui/refresh-button' import { LoansFilters } from './loans-filters' @@ -46,6 +47,8 @@ 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 todayIso = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kolkata' }).format(new Date()) + const currentMonthEnd = endOfMonth(todayIso) const [{ data: txnsRaw }, { data: emiBalRaw }, { data: emiPendingRaw }] = await Promise.all([ loanIds.length ? supabase @@ -59,15 +62,17 @@ 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. + // Installments payable as of this month: unpaid rows due on or before the + // current month's end. Counts anything carried over from earlier months + // plus this month's installment, whether or not its 10th has passed — + // NOT the whole remaining term. emiLoanIds.length ? supabase .from('loan_emi_schedule') .select('loan_id') .in('loan_id', emiLoanIds) .in('status', UNPAID_EMI_STATUSES) + .lte('due_date', currentMonthEnd) : Promise.resolve({ data: [] as { loan_id: string }[] }), ]) @@ -123,7 +128,6 @@ export default async function LoansListPage({ const activeRows = tableRows.filter((r) => r.status === 'active') const pastRows = tableRows.filter((r) => r.status !== 'active') - const todayIso = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Kolkata' }).format(new Date()) return (
diff --git a/src/components/loans-list-table.tsx b/src/components/loans-list-table.tsx index 9a7d175..e4ff989 100644 --- a/src/components/loans-list-table.tsx +++ b/src/components/loans-list-table.tsx @@ -31,8 +31,10 @@ 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. */ + /** Installments payable as of the current month: unpaid rows (scheduled + + * partially paid + overdue) due on or before this month's end — carry-over + * from earlier months plus this month's. NOT the remaining term. + * Null for accrual-model loans. */ pending_emi_count?: number | null balance: number detail_href: string @@ -318,12 +320,22 @@ export function LoansListTable({ 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. + // A plain count of installments due as of this month — not a rupee value. + // More than one means earlier months are still unpaid. body: (l) => - l.repayment_model === 'emi' ? ( - l._pending_emi - ) : ( + l.repayment_model !== 'emi' ? ( + ) : ( + 1 ? 'font-medium text-amber-700' : undefined} + > + {l._pending_emi} + ), }, { diff --git a/src/lib/due.test.ts b/src/lib/due.test.ts index 9822e24..b0ccfb1 100644 --- a/src/lib/due.test.ts +++ b/src/lib/due.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { overdueParts, formatDueLabel, formatOverdueDuration } from './due' +import { overdueParts, formatDueLabel, formatOverdueDuration, endOfMonth } from './due' describe('overdueParts', () => { it('returns null when not yet due', () => { @@ -38,3 +38,21 @@ describe('formatOverdueDuration', () => { expect(formatOverdueDuration({ months: 1, days: 25 })).toBe('1M 25D') }) }) + +describe('endOfMonth', () => { + it('returns the last day of a 31-day month', () => { + expect(endOfMonth('2026-08-06')).toBe('2026-08-31') + }) + it('returns the last day of a 30-day month', () => { + expect(endOfMonth('2026-04-01')).toBe('2026-04-30') + }) + it('handles February in a non-leap year', () => { + expect(endOfMonth('2026-02-14')).toBe('2026-02-28') + }) + it('handles February in a leap year', () => { + expect(endOfMonth('2028-02-14')).toBe('2028-02-29') + }) + it('is idempotent on a date already at month end', () => { + expect(endOfMonth('2026-12-31')).toBe('2026-12-31') + }) +}) diff --git a/src/lib/due.ts b/src/lib/due.ts index 4ddfbee..2b03752 100644 --- a/src/lib/due.ts +++ b/src/lib/due.ts @@ -32,6 +32,18 @@ export function overdueParts(dueIso: string, todayIso: string): OverdueParts | n return { months, days } } +/** + * Last calendar day of `isoDate`'s month, as 'YYYY-MM-DD'. Used as the cut-off + * for "payable as of this month": an installment due on the 10th counts from + * the 1st, not only once the 10th has passed. + */ +export function endOfMonth(isoDate: string): string { + const [y, m] = isoDate.split('-').map(Number) + if (!y || !m) return isoDate + const last = new Date(Date.UTC(y, m, 0)).getUTCDate() + return `${y}-${String(m).padStart(2, '0')}-${String(last).padStart(2, '0')}` +} + /** Compact label like "Due (2M 4D)" or "Due (4D)" (months omitted when zero). */ export function formatDueLabel(parts: OverdueParts): string { const monthPart = parts.months > 0 ? `${parts.months}M ` : ''