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
12 changes: 8 additions & 4 deletions src/app/(app)/admin/loans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 }[] }),
])

Expand Down Expand Up @@ -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 = (
<>
Expand Down
12 changes: 8 additions & 4 deletions src/app/(app)/dashboard/loans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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 }[] }),
])

Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand Down
24 changes: 18 additions & 6 deletions src/components/loans-list-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' ? (
<span className="text-gray-400">—</span>
) : (
<span
title={
l._pending_emi === 0
? 'Nothing due this month'
: `${l._pending_emi} installment${l._pending_emi === 1 ? '' : 's'} payable as of this month`
}
className={l._pending_emi > 1 ? 'font-medium text-amber-700' : undefined}
>
{l._pending_emi}
</span>
),
},
{
Expand Down
20 changes: 19 additions & 1 deletion src/lib/due.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
12 changes: 12 additions & 0 deletions src/lib/due.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ` : ''
Expand Down
Loading