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
61 changes: 61 additions & 0 deletions apps/api/src/aging.route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@opensociety/db')>()),
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<string, number>
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 })
})
})
40 changes: 40 additions & 0 deletions apps/api/src/aging.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
8 changes: 4 additions & 4 deletions apps/api/src/reports.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
67 changes: 67 additions & 0 deletions apps/api/src/routes/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
incomeExpenditure,
balanceSheet,
receiptsAndPayments,
bucketAging,
AGING_BUCKETS,
trialBalanceTable,
incomeExpenditureTable,
balanceSheetTable,
Expand Down Expand Up @@ -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<number>`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),
})
})

Expand Down Expand Up @@ -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<number>`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<string, { label: string; items: { outstanding: number; dueDateMs: number }[] }>()
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) => {
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/finance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgingBucket, number>

// 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'
Expand Down Expand Up @@ -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).
Expand Down
Loading