diff --git a/apps/api/src/aging.route.test.ts b/apps/api/src/aging.route.test.ts new file mode 100644 index 0000000..3ce461f --- /dev/null +++ b/apps/api/src/aging.route.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const { fakeDb, setQueue } = vi.hoisted(() => { + let queue: unknown[][] = [] + const makeChain = (): unknown => + new Proxy(function () {}, { + get(_t, prop) { + if (prop === 'then') { + const r = queue.length ? (queue.shift() as unknown[]) : [] + return (resolve: (v: unknown) => void) => resolve(r) + } + return () => makeChain() + }, + apply: () => makeChain(), + }) + return { fakeDb: () => makeChain(), setQueue: (q: unknown[][]) => (queue = q) } +}) + +vi.mock('@opensociety/db', async (orig) => ({ + ...(await orig()), + createDb: () => fakeDb(), +})) + +const { reportRoutes } = await import('./routes/reports') +import type { AppEnv } from './types' + +const env = { DATABASE_URL: 'test' } as AppEnv['Bindings'] +const ADMIN = { id: 'a1', role: 'ADMIN', status: 'APPROVED' } +const RESIDENT = { id: 'r1', role: 'RESIDENT', status: 'APPROVED' } +const get = (path: string, id?: string) => reportRoutes.request(path, { headers: id ? { 'x-user-id': id } : {} }, env) + +beforeEach(() => setQueue([])) + +describe('GET /reports/aging', () => { + it('403s a resident', async () => { + setQueue([[RESIDENT]]) + expect((await get('/aging', 'r1')).status).toBe(403) + }) + + it('buckets outstanding bills by age for an admin', async () => { + const asOf = '2026-03-31' + const due = (n: number) => new Date(Date.UTC(2026, 2, 31) - n * 86_400_000).toISOString() + setQueue([ + [ADMIN], + [ + { apartmentId: 'apt1', tower: 'A', apartmentNo: '101', total: 10000, dueDate: due(10), paid: 0 }, // 0-30 + { apartmentId: 'apt2', tower: 'B', apartmentNo: '201', total: 50000, dueDate: due(120), paid: 10000 }, // 90+ (40000) + ], + ]) + const res = await get(`/aging?asOf=${asOf}`, 'a1') + expect(res.status).toBe(200) + const body = (await res.json()) as { + buckets: Record + total: number + byApartment: { apartment: string; outstanding: number }[] + } + expect(body.buckets).toEqual({ '0-30': 10000, '31-60': 0, '61-90': 0, '90+': 40000 }) + expect(body.total).toBe(50000) + expect(body.byApartment[0]).toMatchObject({ apartment: 'B-201', outstanding: 40000 }) + }) +}) diff --git a/apps/api/src/aging.test.ts b/apps/api/src/aging.test.ts new file mode 100644 index 0000000..13c9104 --- /dev/null +++ b/apps/api/src/aging.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { agingBucketFor, bucketAging } from '@opensociety/shared' + +const asOf = Date.UTC(2026, 2, 31) // 2026-03-31 +const daysBefore = (n: number) => asOf - n * 86_400_000 + +describe('agingBucketFor', () => { + it('maps days overdue to buckets at the boundaries', () => { + expect(agingBucketFor(0)).toBe('0-30') + expect(agingBucketFor(30)).toBe('0-30') + expect(agingBucketFor(31)).toBe('31-60') + expect(agingBucketFor(60)).toBe('31-60') + expect(agingBucketFor(61)).toBe('61-90') + expect(agingBucketFor(90)).toBe('61-90') + expect(agingBucketFor(91)).toBe('90+') + expect(agingBucketFor(400)).toBe('90+') + }) +}) + +describe('bucketAging', () => { + it('sums outstanding into the right buckets by age', () => { + const items = [ + { outstanding: 10000, dueDateMs: daysBefore(10) }, // 0-30 + { outstanding: 20000, dueDateMs: daysBefore(45) }, // 31-60 + { outstanding: 30000, dueDateMs: daysBefore(75) }, // 61-90 + { outstanding: 40000, dueDateMs: daysBefore(120) }, // 90+ + { outstanding: 5000, dueDateMs: daysBefore(5) }, // 0-30 + ] + expect(bucketAging(items, asOf)).toEqual({ '0-30': 15000, '31-60': 20000, '61-90': 30000, '90+': 40000 }) + }) + + it('treats not-yet-due amounts as current and skips cleared balances', () => { + const items = [ + { outstanding: 8000, dueDateMs: asOf + 10 * 86_400_000 }, // future due -> 0-30 + { outstanding: 0, dueDateMs: daysBefore(200) }, // cleared -> skipped + { outstanding: -500, dueDateMs: daysBefore(200) }, // credit -> skipped + ] + expect(bucketAging(items, asOf)).toEqual({ '0-30': 8000, '31-60': 0, '61-90': 0, '90+': 0 }) + }) +}) diff --git a/apps/api/src/reports.route.test.ts b/apps/api/src/reports.route.test.ts index 7e51df0..9fa0e35 100644 --- a/apps/api/src/reports.route.test.ts +++ b/apps/api/src/reports.route.test.ts @@ -54,11 +54,11 @@ describe('reports routes — admin-only auth contract', () => { describe('reports routes — handler', () => { it('admin GET /finance returns the report shape', async () => { - // user, then billed/collected/methods queries all empty - setQueue([[ADMIN], [], [], []]) + // user, then billed/collected/methods empty, then the cash+bank aggregate + setQueue([[ADMIN], [], [], [], [{ cashBank: 0 }]]) const res = await reportRoutes.request('/finance', as('a1'), env) expect(res.status).toBe(200) - const body = (await res.json()) as { byMonth: unknown[]; totalBilled: number; totalCollected: number } - expect(body).toMatchObject({ byMonth: [], totalBilled: 0, totalCollected: 0 }) + const body = (await res.json()) as { byMonth: unknown[]; totalBilled: number; totalCollected: number; cashBankBalance: number } + expect(body).toMatchObject({ byMonth: [], totalBilled: 0, totalCollected: 0, cashBankBalance: 0 }) }) }) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 7a5f639..f99cf2f 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -26,6 +26,8 @@ import { incomeExpenditure, balanceSheet, receiptsAndPayments, + bucketAging, + AGING_BUCKETS, trialBalanceTable, incomeExpenditureTable, balanceSheetTable, @@ -97,11 +99,19 @@ reportRoutes.get('/finance', async (c) => { .from(payments) .groupBy(payments.method) + // Cash + bank balance from the ledger (0 until the GL is initialized). + const [{ cashBank }] = await db + .select({ cashBank: sql`coalesce(sum(${journalLines.debit} - ${journalLines.credit}), 0)` }) + .from(journalLines) + .innerJoin(accounts, eq(accounts.id, journalLines.accountId)) + .where(and(sql`${accounts.code} like '10%'`, eq(accounts.isGroup, false))) + return c.json({ byMonth, byMethod: methods.map((m) => ({ method: m.method, amount: Number(m.amount) })).sort((a, b) => b.amount - a.amount), totalBilled: byMonth.reduce((s, r) => s + r.billed, 0), totalCollected: byMonth.reduce((s, r) => s + r.collected, 0), + cashBankBalance: Number(cashBank), }) }) @@ -411,6 +421,63 @@ reportRoutes.get('/tally-export', async (c) => { }) }) +// Aging analysis of outstanding dues (#100): buckets 0-30 / 31-60 / 61-90 / 90+ +// days past due, both in total and per flat. ?asOf=YYYY-MM-DD (default: today). +reportRoutes.get('/aging', async (c) => { + const db = c.get('db') + const asOf = c.req.query('asOf') ? new Date(`${c.req.query('asOf')}T23:59:59.999Z`) : new Date() + const asOfMs = asOf.getTime() + + const rows = await db + .select({ + apartmentId: maintenanceBills.apartmentId, + tower: apartments.tower, + apartmentNo: apartments.apartmentNo, + total: maintenanceBills.totalAmount, + dueDate: maintenanceBills.dueDate, + paid: sql`coalesce(sum(${payments.amount}), 0)`, + }) + .from(maintenanceBills) + .innerJoin(apartments, eq(apartments.id, maintenanceBills.apartmentId)) + .leftJoin(payments, eq(payments.billId, maintenanceBills.id)) + .where(and(isNotNull(maintenanceBills.dueDate), sql`${maintenanceBills.status} <> 'CANCELLED'`)) + .groupBy( + maintenanceBills.id, + maintenanceBills.apartmentId, + apartments.tower, + apartments.apartmentNo, + maintenanceBills.totalAmount, + maintenanceBills.dueDate, + ) + + const perApt = new Map() + const allItems: { outstanding: number; dueDateMs: number }[] = [] + for (const r of rows) { + const outstanding = Number(r.total) - Number(r.paid) + if (outstanding <= 0 || !r.dueDate) continue + const item = { outstanding, dueDateMs: new Date(r.dueDate as unknown as string).getTime() } + allItems.push(item) + const e = perApt.get(r.apartmentId) ?? { label: `${r.tower}-${r.apartmentNo}`, items: [] } + e.items.push(item) + perApt.set(r.apartmentId, e) + } + + const buckets = bucketAging(allItems, asOfMs) + const byApartment = [...perApt.entries()] + .map(([apartmentId, v]) => { + const b = bucketAging(v.items, asOfMs) + return { apartmentId, apartment: v.label, buckets: b, outstanding: AGING_BUCKETS.reduce((s, k) => s + b[k], 0) } + }) + .sort((a, b) => b.outstanding - a.outstanding) + + return c.json({ + asOf: asOf.toISOString().slice(0, 10), + buckets, + total: AGING_BUCKETS.reduce((s, k) => s + buckets[k], 0), + byApartment, + }) +}) + // House-help insights (#69): active-help count, most-employed types, and // attendance patterns by day of week (IST). reportRoutes.get('/house-help-analytics', async (c) => { diff --git a/packages/shared/src/finance.ts b/packages/shared/src/finance.ts index e4eff23..0bdb3cc 100644 --- a/packages/shared/src/finance.ts +++ b/packages/shared/src/finance.ts @@ -176,6 +176,33 @@ export function sumApartmentInterest( // can be told apart from ordinary charges in statements/reports. export const INTEREST_BILL_TITLE_PREFIX = 'Interest on arrears' +// ----- Aging buckets (#100) ----- + +export type AgingBucket = '0-30' | '31-60' | '61-90' | '90+' +export const AGING_BUCKETS: AgingBucket[] = ['0-30', '31-60', '61-90', '90+'] +export type AgingSummary = Record + +// Which aging bucket a number of days overdue falls into. Amounts not yet due +// (days <= 0) count as current (0-30). +export function agingBucketFor(daysOverdue: number): AgingBucket { + if (daysOverdue <= 30) return '0-30' + if (daysOverdue <= 60) return '31-60' + if (daysOverdue <= 90) return '61-90' + return '90+' +} + +// Bucket a set of outstanding amounts (each with its due date) by age as of +// `asOfMs`. Non-positive balances are skipped. +export function bucketAging(items: { outstanding: number; dueDateMs: number }[], asOfMs: number): AgingSummary { + const out: AgingSummary = { '0-30': 0, '31-60': 0, '61-90': 0, '90+': 0 } + for (const it of items) { + if (it.outstanding <= 0) continue + const days = Math.max(0, Math.floor((asOfMs - it.dueDateMs) / 86_400_000)) + out[agingBucketFor(days)] += it.outstanding + } + return out +} + // ----- Per-flat statement of account (#96) ----- export type StatementEntryType = 'BILL' | 'INTEREST' | 'PAYMENT' | 'ADJUSTMENT' @@ -224,6 +251,7 @@ export type FinanceReport = { byMethod: MethodRow[] totalBilled: number totalCollected: number + cashBankBalance: number } // Collection rate as a percentage with one decimal (0 when nothing is billed).