From fba3f8b84645044e6b7c281bd5527bd049b6a295 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Thu, 16 Jul 2026 16:47:51 +0530 Subject: [PATCH 1/2] feat(finance): per-flat statement of account (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dated, running-balance ledger per apartment — the "My Statement" every benchmark app has and auditors expect. - buildStatement (shared): merges charges (bills + interest = debits) and payments (credits) into a chronological running balance. Debit-positive (what the flat owes); closing = opening + Σdebit − Σcredit, reconciling to outstanding dues. Opening carries in everything before the window. - API GET /apartments/:id/statement?from=&to= — role-scoped (residents only their flats, ADMIN any), excludes CANCELLED bills, tags auto interest bills as INTEREST via the shared title prefix. - Shared INTEREST_BILL_TITLE_PREFIX constant (bills.ts now reuses it). - Tests: buildStatement (running balance, opening, sort, empty) + statement route (401/403 scoping, admin running balance). Suite 340 green. Web (billing statement view) + mobile (bills.tsx) UI land in the consolidated M3.5 admin-UI pass. --- apps/api/src/routes/apartments.ts | 93 +++++++++++++++++++++++++++- apps/api/src/routes/bills.ts | 3 +- apps/api/src/statement.route.test.ts | 59 ++++++++++++++++++ apps/api/src/statement.test.ts | 35 +++++++++++ packages/shared/src/finance.ts | 45 ++++++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/statement.route.test.ts create mode 100644 apps/api/src/statement.test.ts diff --git a/apps/api/src/routes/apartments.ts b/apps/api/src/routes/apartments.ts index 3e2b739..4dcbed6 100644 --- a/apps/api/src/routes/apartments.ts +++ b/apps/api/src/routes/apartments.ts @@ -1,8 +1,15 @@ import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' -import { and, asc, eq, inArray, isNull } from 'drizzle-orm' -import { apartments, residencies, users } from '@opensociety/db' -import { createApartmentSchema, createApartmentsBulkSchema, updateApartmentSchema } from '@opensociety/shared' +import { and, asc, eq, inArray, isNull, ne } from 'drizzle-orm' +import { apartments, residencies, users, maintenanceBills, payments } from '@opensociety/db' +import { + createApartmentSchema, + createApartmentsBulkSchema, + updateApartmentSchema, + buildStatement, + INTEREST_BILL_TITLE_PREFIX, + type StatementEntryInput, +} from '@opensociety/shared' import { withDb, withAuth, requireAuth, requireRole, actingUserId } from '../middleware' import type { AppEnv } from '../types' @@ -58,6 +65,86 @@ apartmentRoutes.get('/mine/residents', async (c) => { return c.json(rows) }) +// Per-flat statement of account (#96): a dated, running-balance ledger merging +// charges (bills + interest, debits) and payments (credits) over an optional +// [from,to] window. Balance is debit-positive (what the flat owes); closing +// reconciles to outstanding dues. ADMIN sees any flat; residents only their own. +apartmentRoutes.get('/:id/statement', async (c) => { + const db = c.get('db') + const id = c.req.param('id') + if (c.get('userRole') !== 'ADMIN') { + const mine = await db + .select({ id: residencies.apartmentId }) + .from(residencies) + .where(and(eq(residencies.userId, actingUserId(c)!), isNull(residencies.endDate))) + if (!mine.some((r) => r.id === id)) return c.json({ error: 'forbidden' }, 403) + } + const [apt] = await db.select().from(apartments).where(eq(apartments.id, id)).limit(1) + if (!apt) return c.json({ error: 'not found' }, 404) + + const fromMs = c.req.query('from') ? new Date(`${c.req.query('from')}T00:00:00.000Z`).getTime() : null + const toMs = c.req.query('to') ? new Date(`${c.req.query('to')}T23:59:59.999Z`).getTime() : null + + const bills = await db + .select({ + id: maintenanceBills.id, + title: maintenanceBills.title, + total: maintenanceBills.totalAmount, + issuedAt: maintenanceBills.issuedAt, + }) + .from(maintenanceBills) + .where(and(eq(maintenanceBills.apartmentId, id), ne(maintenanceBills.status, 'CANCELLED'))) + const pays = await db + .select({ id: payments.id, amount: payments.amount, method: payments.method, paidAt: payments.paidAt }) + .from(payments) + .where(eq(payments.apartmentId, id)) + + const all: StatementEntryInput[] = [] + for (const b of bills) { + const isInterest = b.title.startsWith(INTEREST_BILL_TITLE_PREFIX) + all.push({ + date: new Date(b.issuedAt as unknown as string).toISOString(), + type: isInterest ? 'INTEREST' : 'BILL', + description: b.title, + debit: b.total, + credit: 0, + ref: b.id, + }) + } + for (const p of pays) { + all.push({ + date: new Date(p.paidAt as unknown as string).toISOString(), + type: 'PAYMENT', + description: `Payment (${p.method})`, + debit: 0, + credit: p.amount, + ref: p.id, + }) + } + + // Opening balance = net of everything strictly before the window start. + let opening = 0 + const windowRows: StatementEntryInput[] = [] + for (const e of all) { + const ms = new Date(e.date).getTime() + if (fromMs != null && ms < fromMs) { + opening += e.debit - e.credit + continue + } + if (toMs != null && ms > toMs) continue + windowRows.push(e) + } + + const statement = buildStatement(windowRows, opening) + return c.json({ + apartmentId: id, + apartment: `${apt.tower}-${apt.apartmentNo}`, + from: c.req.query('from') ?? null, + to: c.req.query('to') ?? null, + ...statement, + }) +}) + apartmentRoutes.post('/', requireRole('ADMIN'), zValidator('json', createApartmentSchema), async (c) => { const [created] = await c.get('db').insert(apartments).values(c.req.valid('json')).returning() return c.json(created, 201) diff --git a/apps/api/src/routes/bills.ts b/apps/api/src/routes/bills.ts index ee47a8a..3c0c19c 100644 --- a/apps/api/src/routes/bills.ts +++ b/apps/api/src/routes/bills.ts @@ -12,13 +12,12 @@ import { sumApartmentInterest, periodMonthOf, ACCOUNT_CODES, + INTEREST_BILL_TITLE_PREFIX as INTEREST_TITLE_PREFIX, } from '@opensociety/shared' import { withDb, withAuth, requireAuth, requireRole, actingUserId } from '../middleware' import { generateMonthlyBills } from '../lib/generate-bills' import { safePostBill, safeReverseBill, resolveAccounts } from '../lib/ledger-posting' import { renderInvoicePdf } from '../lib/invoice-pdf' - -const INTEREST_TITLE_PREFIX = 'Interest on arrears' import type { AppEnv } from '../types' // Apartment ids the acting user currently lives in (open residencies). diff --git a/apps/api/src/statement.route.test.ts b/apps/api/src/statement.route.test.ts new file mode 100644 index 0000000..b46cf7f --- /dev/null +++ b/apps/api/src/statement.route.test.ts @@ -0,0 +1,59 @@ +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 { apartmentRoutes } = await import('./routes/apartments') +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) => + apartmentRoutes.request(path, { headers: id ? { 'x-user-id': id } : {} }, env) + +beforeEach(() => setQueue([])) + +describe('GET /apartments/:id/statement', () => { + it('401s an unauthenticated request', async () => { + expect((await get('/apt1/statement')).status).toBe(401) + }) + + it('403s a resident for a flat that is not theirs', async () => { + setQueue([[RESIDENT], []]) // auth, then empty residency scope + expect((await get('/apt1/statement', 'r1')).status).toBe(403) + }) + + it('returns a running-balance statement for an admin', async () => { + setQueue([ + [ADMIN], + [{ id: 'apt1', tower: 'A', apartmentNo: '101' }], + [{ id: 'b1', title: 'Maintenance Jan', total: 100000, issuedAt: '2026-01-05T00:00:00.000Z' }], + [{ id: 'p1', amount: 60000, method: 'UPI', paidAt: '2026-01-20T00:00:00.000Z' }], + ]) + const res = await get('/apt1/statement', 'a1') + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ apartment: 'A-101', opening: 0, closing: 40000 }) + expect(body.entries).toHaveLength(2) + expect(body.entries[1]).toMatchObject({ type: 'PAYMENT', balance: 40000 }) + }) +}) diff --git a/apps/api/src/statement.test.ts b/apps/api/src/statement.test.ts new file mode 100644 index 0000000..bae1026 --- /dev/null +++ b/apps/api/src/statement.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { buildStatement, type StatementEntryInput } from '@opensociety/shared' + +const rows: StatementEntryInput[] = [ + { date: '2026-01-05T00:00:00.000Z', type: 'BILL', description: 'Maintenance Jan', debit: 100000, credit: 0 }, + { date: '2026-01-20T00:00:00.000Z', type: 'PAYMENT', description: 'Payment', debit: 0, credit: 60000 }, + { date: '2026-02-05T00:00:00.000Z', type: 'BILL', description: 'Maintenance Feb', debit: 100000, credit: 0 }, +] + +describe('buildStatement', () => { + it('produces a chronological running balance', () => { + const s = buildStatement(rows) + expect(s.entries.map((e) => e.balance)).toEqual([100000, 40000, 140000]) + expect(s.opening).toBe(0) + expect(s.closing).toBe(140000) + expect(s.totalDebit).toBe(200000) + expect(s.totalCredit).toBe(60000) + }) + + it('closing reconciles to Σdebit − Σcredit + opening', () => { + const s = buildStatement(rows, 5000) + expect(s.closing).toBe(5000 + 200000 - 60000) + expect(s.entries[0].balance).toBe(5000 + 100000) + }) + + it('sorts out-of-order rows by date', () => { + const shuffled = [rows[2], rows[0], rows[1]] + const s = buildStatement(shuffled) + expect(s.entries.map((e) => e.description)).toEqual(['Maintenance Jan', 'Payment', 'Maintenance Feb']) + }) + + it('is empty-safe', () => { + expect(buildStatement([], 250)).toMatchObject({ opening: 250, closing: 250, entries: [], totalDebit: 0, totalCredit: 0 }) + }) +}) diff --git a/packages/shared/src/finance.ts b/packages/shared/src/finance.ts index 8b65234..e4eff23 100644 --- a/packages/shared/src/finance.ts +++ b/packages/shared/src/finance.ts @@ -172,6 +172,51 @@ export function sumApartmentInterest( return bills.reduce((s, b) => s + computeInterest(b.outstanding, b.dueDateMs, asOfMs, ratePct, graceDays), 0) } +// Title prefix marking auto-generated interest-on-arrears bills (#95), so they +// can be told apart from ordinary charges in statements/reports. +export const INTEREST_BILL_TITLE_PREFIX = 'Interest on arrears' + +// ----- Per-flat statement of account (#96) ----- + +export type StatementEntryType = 'BILL' | 'INTEREST' | 'PAYMENT' | 'ADJUSTMENT' + +export type StatementEntryInput = { + date: string // ISO timestamp + type: StatementEntryType + description: string + debit: number // paise (charges raise the balance) + credit: number // paise (payments lower it) + ref?: string | null // source bill/payment id +} + +export type StatementEntry = StatementEntryInput & { balance: number } + +export type Statement = { + opening: number + closing: number + totalDebit: number + totalCredit: number + entries: StatementEntry[] +} + +// Merge a flat's charges (debits) and payments (credits) into a dated, +// running-balance statement. Balance is debit-positive = amount the flat owes; +// closing = opening + Σdebit − Σcredit, which reconciles to outstanding dues. +// `opening` is the net balance carried in from before the statement window. +export function buildStatement(rows: StatementEntryInput[], opening = 0): Statement { + const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date)) + let balance = opening + let totalDebit = 0 + let totalCredit = 0 + const entries = sorted.map((r) => { + balance += r.debit - r.credit + totalDebit += r.debit + totalCredit += r.credit + return { ...r, balance } + }) + return { opening, closing: balance, totalDebit, totalCredit, entries } +} + export type CollectionRow = { period: string; billed: number; collected: number } export type MethodRow = { method: string; amount: number } export type FinanceReport = { From 6040d22d9db292fb2da5b1211a72a906e94f60a8 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Thu, 16 Jul 2026 16:51:52 +0530 Subject: [PATCH 2/2] test(finance): type the statement route test json (CI build fix) --- apps/api/src/statement.route.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/statement.route.test.ts b/apps/api/src/statement.route.test.ts index b46cf7f..2e607a8 100644 --- a/apps/api/src/statement.route.test.ts +++ b/apps/api/src/statement.route.test.ts @@ -51,7 +51,12 @@ describe('GET /apartments/:id/statement', () => { ]) const res = await get('/apt1/statement', 'a1') expect(res.status).toBe(200) - const body = await res.json() + const body = (await res.json()) as { + apartment: string + opening: number + closing: number + entries: { type: string; balance: number }[] + } expect(body).toMatchObject({ apartment: 'A-101', opening: 0, closing: 40000 }) expect(body.entries).toHaveLength(2) expect(body.entries[1]).toMatchObject({ type: 'PAYMENT', balance: 40000 })