From 9325803d2e07db4ed9e3a1333554ae2d05321c24 Mon Sep 17 00:00:00 2001 From: ankitsejwal Date: Thu, 16 Jul 2026 17:00:07 +0530 Subject: [PATCH] feat(finance): statutory financial statements (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trial Balance, Income & Expenditure, Balance Sheet, and Receipts & Payments — derived purely off the ledger (design §8). The statutory deliverable (Rule 62 MCS Rules) that lets the app be a society's system of record. - shared/statements.ts (pure, unit-tested): trialBalance (nets to zero), incomeExpenditure (accrual; surplus + mutual/taxable split for ITR-5), balanceSheet (assets vs liabilities+funds+current surplus; balances by construction), receiptsAndPayments (cash basis via contra legs of cash/bank entries, opening+closing). - API (reports.ts, ADMIN): /reports/trial-balance, /income-expenditure, /balance-sheet, /receipts-payments — all ?fromDate&toDate. accountBalances aggregates journal_lines over the entry-date window. - Tests on a genuinely balanced mini-ledger: TB nets to zero, BS balances, I&E surplus + mutual split, R&P separates receipts/payments; + route auth tests. Suite 349 green. Web reports.tsx rendering + drill-down land in the M3.5 admin-UI pass. Maharashtra Rule-62-strict formatting deferred (generic mode first, design §9.6). --- apps/api/src/reports-statements.route.test.ts | 51 +++++ apps/api/src/routes/reports.ts | 127 +++++++++++- apps/api/src/statements.test.ts | 83 ++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/statements.ts | 190 ++++++++++++++++++ 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/reports-statements.route.test.ts create mode 100644 apps/api/src/statements.test.ts create mode 100644 packages/shared/src/statements.ts diff --git a/apps/api/src/reports-statements.route.test.ts b/apps/api/src/reports-statements.route.test.ts new file mode 100644 index 0000000..7ca7727 --- /dev/null +++ b/apps/api/src/reports-statements.route.test.ts @@ -0,0 +1,51 @@ +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('statutory statement reports — admin only', () => { + it('403s a resident on /trial-balance', async () => { + setQueue([[RESIDENT]]) + expect((await get('/trial-balance', 'r1')).status).toBe(403) + }) + + it('returns a (balanced, empty) trial balance for an admin', async () => { + setQueue([[ADMIN], []]) // auth, then the account-balances aggregate -> [] + const res = await get('/trial-balance', 'a1') + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ rows: [], totalDebit: 0, totalCredit: 0, balanced: true }) + }) + + it('403s a resident on /balance-sheet', async () => { + setQueue([[RESIDENT]]) + expect((await get('/balance-sheet', 'r1')).status).toBe(403) + }) +}) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index b89b418..1672b76 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { Context } from 'hono' -import { and, eq, gte, isNotNull, lte, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, isNotNull, lt, lte, sql } from 'drizzle-orm' import { maintenanceBills, payments, @@ -10,6 +10,9 @@ import { houseHelp, houseHelpEntries, maintenanceTickets, + accounts, + journalEntries, + journalLines, } from '@opensociety/db' import { analyzePayers, @@ -19,6 +22,12 @@ import { averagePerDay, fillDaysOfWeek, avgResolutionHours, + trialBalance, + incomeExpenditure, + balanceSheet, + receiptsAndPayments, + type AccountBalance, + type CashEntryLine, } from '@opensociety/shared' import type { VisitorTrends } from '@opensociety/shared' import { withDb, withAuth, requireRole } from '../middleware' @@ -196,6 +205,122 @@ async function computeVisitorTrends(c: Context): Promise reportRoutes.get('/visitor-trends', async (c) => c.json(await computeVisitorTrends(c))) +// ----- Statutory financial statements (#98), derived off the ledger ----- + +// Per-account debit/credit totals over an optional entry-date window. +async function accountBalances( + c: Context, + opts: { fromDate?: string; toDate?: string }, +): Promise { + const conds = [] + if (opts.fromDate) conds.push(gte(journalEntries.entryDate, opts.fromDate)) + if (opts.toDate) conds.push(lte(journalEntries.entryDate, opts.toDate)) + const rows = await c + .get('db') + .select({ + code: accounts.code, + name: accounts.name, + type: accounts.type, + isMutual: accounts.isMutual, + debit: sql`coalesce(sum(${journalLines.debit}), 0)`, + credit: sql`coalesce(sum(${journalLines.credit}), 0)`, + }) + .from(journalLines) + .innerJoin(journalEntries, eq(journalEntries.id, journalLines.entryId)) + .innerJoin(accounts, eq(accounts.id, journalLines.accountId)) + .where(conds.length ? and(...conds) : undefined) + .groupBy(accounts.code, accounts.name, accounts.type, accounts.isMutual) + return rows.map((r) => ({ ...r, debit: Number(r.debit), credit: Number(r.credit) })) +} + +// Trial Balance as of ?toDate (default: all). Nets to zero. +reportRoutes.get('/trial-balance', async (c) => { + const toDate = c.req.query('toDate') + return c.json({ toDate: toDate ?? null, ...trialBalance(await accountBalances(c, { toDate })) }) +}) + +// Income & Expenditure (accrual) for ?fromDate..?toDate. +reportRoutes.get('/income-expenditure', async (c) => { + const fromDate = c.req.query('fromDate') + const toDate = c.req.query('toDate') + return c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, ...incomeExpenditure(await accountBalances(c, { fromDate, toDate })) }) +}) + +// Balance Sheet as of ?toDate. Current surplus = I&E surplus up to the same date +// so the sheet balances. +reportRoutes.get('/balance-sheet', async (c) => { + const toDate = c.req.query('toDate') + const balances = await accountBalances(c, { toDate }) + const surplus = incomeExpenditure(balances).surplus + return c.json({ toDate: toDate ?? null, ...balanceSheet(balances, surplus) }) +}) + +// Receipts & Payments (cash basis) for ?fromDate..?toDate. +reportRoutes.get('/receipts-payments', async (c) => { + const db = c.get('db') + const fromDate = c.req.query('fromDate') + const toDate = c.req.query('toDate') + + // Cash/bank leaf accounts (codes 10xx, e.g. Bank, Cash in Hand). + const cashAccts = await db + .select({ id: accounts.id }) + .from(accounts) + .where(and(sql`${accounts.code} like '10%'`, eq(accounts.isGroup, false))) + const cashIds = cashAccts.map((a) => a.id) + if (cashIds.length === 0) { + return c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, openingBalance: 0, receipts: [], payments: [], totalReceipts: 0, totalPayments: 0, closingBalance: 0 }) + } + + // Opening cash balance = Σ(debit − credit) on cash accounts before the window. + let openingBalance = 0 + if (fromDate) { + const [{ opening }] = await db + .select({ opening: sql`coalesce(sum(${journalLines.debit} - ${journalLines.credit}), 0)` }) + .from(journalLines) + .innerJoin(journalEntries, eq(journalEntries.id, journalLines.entryId)) + .where(and(inArray(journalLines.accountId, cashIds), lt(journalEntries.entryDate, fromDate))) + openingBalance = Number(opening) + } + + // Entries that touch a cash account within the window. + const entryConds = [inArray(journalLines.accountId, cashIds)] + if (fromDate) entryConds.push(gte(journalEntries.entryDate, fromDate)) + if (toDate) entryConds.push(lte(journalEntries.entryDate, toDate)) + const entryRows = await db + .selectDistinct({ entryId: journalLines.entryId }) + .from(journalLines) + .innerJoin(journalEntries, eq(journalEntries.id, journalLines.entryId)) + .where(and(...entryConds)) + const entryIds = entryRows.map((r) => r.entryId) + if (entryIds.length === 0) { + return c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, openingBalance, receipts: [], payments: [], totalReceipts: 0, totalPayments: 0, closingBalance: openingBalance }) + } + + const cashSet = new Set(cashIds) + const lineRows = await db + .select({ + entryId: journalLines.entryId, + accountId: journalLines.accountId, + code: accounts.code, + name: accounts.name, + debit: journalLines.debit, + credit: journalLines.credit, + }) + .from(journalLines) + .innerJoin(accounts, eq(accounts.id, journalLines.accountId)) + .where(inArray(journalLines.entryId, entryIds)) + const lines: CashEntryLine[] = lineRows.map((l) => ({ + entryId: l.entryId, + accountCode: l.code, + accountName: l.name, + isCashBank: cashSet.has(l.accountId), + debit: l.debit, + credit: l.credit, + })) + + return c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, ...receiptsAndPayments(lines, openingBalance) }) +}) + // 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/apps/api/src/statements.test.ts b/apps/api/src/statements.test.ts new file mode 100644 index 0000000..ab41e34 --- /dev/null +++ b/apps/api/src/statements.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + trialBalance, + incomeExpenditure, + balanceSheet, + receiptsAndPayments, + type AccountBalance, + type CashEntryLine, +} from '@opensociety/shared' + +// A tiny but complete, genuinely balanced society ledger (Σdebit = Σcredit), +// from: opening bank 1000 (Cr surplus), a 1000+18% GST bill, its payment, 200 FD +// interest received, and a 200 security expense. +const balances: AccountBalance[] = [ + { code: '1001', name: 'Bank — Primary', type: 'ASSET', isMutual: null, debit: 238000, credit: 20000 }, + { code: '1100', name: 'Member Dues Receivable', type: 'ASSET', isMutual: null, debit: 118000, credit: 118000 }, + { code: '2200', name: 'GST Output Payable', type: 'LIABILITY', isMutual: null, debit: 0, credit: 18000 }, + { code: '3300', name: 'Accumulated Surplus', type: 'FUND', isMutual: null, debit: 0, credit: 100000 }, + { code: '4000', name: 'Maintenance Income', type: 'INCOME', isMutual: true, debit: 0, credit: 100000 }, + { code: '4800', name: 'Interest on FD', type: 'INCOME', isMutual: false, debit: 0, credit: 20000 }, + { code: '5010', name: 'Security', type: 'EXPENSE', isMutual: null, debit: 20000, credit: 0 }, +] + +describe('trialBalance', () => { + it('nets to zero (debits == credits)', () => { + const tb = trialBalance(balances) + expect(tb.balanced).toBe(true) + expect(tb.totalDebit).toBe(tb.totalCredit) + }) + it('omits zero-balance accounts and picks the right column', () => { + const tb = trialBalance(balances) + const income = tb.rows.find((r) => r.code === '4000') + expect(income).toMatchObject({ debit: 0, credit: 100000 }) // credit-normal + const bank = tb.rows.find((r) => r.code === '1001') + expect(bank).toMatchObject({ debit: 218000, credit: 0 }) // debit-normal net + }) +}) + +describe('incomeExpenditure', () => { + it('computes surplus and the mutual/taxable split', () => { + const ie = incomeExpenditure(balances) + expect(ie.totalIncome).toBe(120000) + expect(ie.totalExpense).toBe(20000) + expect(ie.surplus).toBe(100000) + expect(ie.mutualIncome).toBe(100000) + expect(ie.nonMutualIncome).toBe(20000) // FD interest is taxable + }) +}) + +describe('balanceSheet', () => { + it('balances assets against liabilities + funds + current surplus', () => { + const surplus = incomeExpenditure(balances).surplus + const bs = balanceSheet(balances, surplus) + expect(bs.balanced).toBe(true) + expect(bs.totalAssets).toBe(bs.totalLiabilitiesAndFunds) + // assets: bank 218000 (receivable nets to 0, omitted) + expect(bs.totalAssets).toBe(218000) + }) +}) + +describe('receiptsAndPayments', () => { + const lines: CashEntryLine[] = [ + // Receipt: member payment 100000 into bank + { entryId: 'e1', accountCode: '1001', accountName: 'Bank', isCashBank: true, debit: 100000, credit: 0 }, + { entryId: 'e1', accountCode: '1100', accountName: 'Member Dues Receivable', isCashBank: false, debit: 0, credit: 100000 }, + // Payment: security expense 20000 out of bank + { entryId: 'e2', accountCode: '1001', accountName: 'Bank', isCashBank: true, debit: 0, credit: 20000 }, + { entryId: 'e2', accountCode: '5010', accountName: 'Security', isCashBank: false, debit: 20000, credit: 0 }, + ] + + it('separates receipts from payments by cash direction', () => { + const rp = receiptsAndPayments(lines, 5000) + expect(rp.totalReceipts).toBe(100000) + expect(rp.totalPayments).toBe(20000) + expect(rp.receipts[0]).toMatchObject({ code: '1100', amount: 100000 }) + expect(rp.payments[0]).toMatchObject({ code: '5010', amount: 20000 }) + expect(rp.closingBalance).toBe(5000 + 100000 - 20000) + }) + + it('is empty-safe', () => { + expect(receiptsAndPayments([], 0)).toMatchObject({ totalReceipts: 0, totalPayments: 0, closingBalance: 0 }) + }) +}) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 17f0383..c07c520 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,4 +25,5 @@ export * from './finance' export * from './reconciliation' export * from './ledger' export * from './expense' +export * from './statements' export * from './i18n' diff --git a/packages/shared/src/statements.ts b/packages/shared/src/statements.ts new file mode 100644 index 0000000..adcaabb --- /dev/null +++ b/packages/shared/src/statements.ts @@ -0,0 +1,190 @@ +import type { AccountType } from './enums' + +// Statutory financial statements (#98). All are pure aggregations over the +// ledger — no storage. See design §8. Money is integer paise. Balance Sheet / +// I&E use accrual; Receipts & Payments is cash-basis (bank/cash movements). + +// Per-account debit/credit totals (Σ over journal_lines), joined to the account. +export type AccountBalance = { + code: string + name: string + type: AccountType + isMutual: boolean | null + debit: number + credit: number +} + +// Net balance in an account's normal direction: debit-normal for ASSET/EXPENSE, +// credit-normal for LIABILITY/INCOME/FUND. Positive = a "normal" balance. +export function normalBalance(b: { type: AccountType; debit: number; credit: number }): number { + return b.type === 'ASSET' || b.type === 'EXPENSE' ? b.debit - b.credit : b.credit - b.debit +} + +// ----- Trial Balance ----- + +export type TrialBalanceRow = { code: string; name: string; type: AccountType; debit: number; credit: number } +export type TrialBalance = { rows: TrialBalanceRow[]; totalDebit: number; totalCredit: number; balanced: boolean } + +// Each account's net lands in its debit or credit column; the two columns must +// be equal (proves ledger integrity). Zero-balance accounts are omitted. +export function trialBalance(balances: AccountBalance[]): TrialBalance { + const rows: TrialBalanceRow[] = [] + for (const b of balances) { + const net = b.debit - b.credit + if (net === 0) continue + rows.push({ code: b.code, name: b.name, type: b.type, debit: net > 0 ? net : 0, credit: net < 0 ? -net : 0 }) + } + rows.sort((a, b) => a.code.localeCompare(b.code)) + const totalDebit = rows.reduce((s, r) => s + r.debit, 0) + const totalCredit = rows.reduce((s, r) => s + r.credit, 0) + return { rows, totalDebit, totalCredit, balanced: totalDebit === totalCredit } +} + +// ----- Income & Expenditure (accrual) ----- + +export type IeRow = { code: string; name: string; amount: number; isMutual?: boolean | null } +export type IncomeExpenditure = { + income: IeRow[] + expense: IeRow[] + totalIncome: number + totalExpense: number + surplus: number + mutualIncome: number + nonMutualIncome: number +} + +// Income (credit-normal) vs expenditure (debit-normal) for the period; surplus = +// income − expense. Income is split mutual (exempt) vs non-mutual (taxable) for +// the ITR-5 view — is_mutual === false is taxable; anything else counts mutual. +export function incomeExpenditure(balances: AccountBalance[]): IncomeExpenditure { + const income = balances + .filter((b) => b.type === 'INCOME') + .map((b) => ({ code: b.code, name: b.name, isMutual: b.isMutual, amount: b.credit - b.debit })) + .filter((r) => r.amount !== 0) + const expense = balances + .filter((b) => b.type === 'EXPENSE') + .map((b) => ({ code: b.code, name: b.name, amount: b.debit - b.credit })) + .filter((r) => r.amount !== 0) + income.sort((a, b) => a.code.localeCompare(b.code)) + expense.sort((a, b) => a.code.localeCompare(b.code)) + const totalIncome = income.reduce((s, r) => s + r.amount, 0) + const totalExpense = expense.reduce((s, r) => s + r.amount, 0) + const nonMutualIncome = income.filter((r) => r.isMutual === false).reduce((s, r) => s + r.amount, 0) + const mutualIncome = totalIncome - nonMutualIncome + return { income, expense, totalIncome, totalExpense, surplus: totalIncome - totalExpense, mutualIncome, nonMutualIncome } +} + +// ----- Balance Sheet (as-of) ----- + +export type BsRow = { code: string; name: string; amount: number } +export type BalanceSheet = { + assets: BsRow[] + liabilities: BsRow[] + funds: BsRow[] + totalAssets: number + totalLiabilities: number + totalFunds: number + currentSurplus: number + totalLiabilitiesAndFunds: number + balanced: boolean +} + +// Assets (debit-normal) vs liabilities + funds + current surplus (credit-normal). +// `currentSurplus` is the I&E surplus up to the same date; with it the sheet +// balances because every entry is balanced (Σdebit = Σcredit). +export function balanceSheet(balances: AccountBalance[], currentSurplus: number): BalanceSheet { + const pick = (type: AccountType) => + balances + .filter((b) => b.type === type) + .map((b) => ({ code: b.code, name: b.name, amount: normalBalance(b) })) + .filter((r) => r.amount !== 0) + .sort((a, b) => a.code.localeCompare(b.code)) + const assets = pick('ASSET') + const liabilities = pick('LIABILITY') + const funds = pick('FUND') + const totalAssets = assets.reduce((s, r) => s + r.amount, 0) + const totalLiabilities = liabilities.reduce((s, r) => s + r.amount, 0) + const totalFunds = funds.reduce((s, r) => s + r.amount, 0) + const totalLiabilitiesAndFunds = totalLiabilities + totalFunds + currentSurplus + return { + assets, + liabilities, + funds, + totalAssets, + totalLiabilities, + totalFunds, + currentSurplus, + totalLiabilitiesAndFunds, + balanced: totalAssets === totalLiabilitiesAndFunds, + } +} + +// ----- Receipts & Payments (cash basis) ----- + +// A journal line belonging to an entry that touches a cash/bank account, tagged +// so the shaper can separate the cash leg from its contra legs. +export type CashEntryLine = { + entryId: string + accountCode: string + accountName: string + isCashBank: boolean + debit: number + credit: number +} + +export type RpRow = { code: string; name: string; amount: number } +export type ReceiptsPayments = { + openingBalance: number + receipts: RpRow[] + payments: RpRow[] + totalReceipts: number + totalPayments: number + closingBalance: number +} + +// Group the contra legs of every cash/bank entry into receipts (entries where +// cash rose) and payments (entries where cash fell). `openingBalance` is the +// cash+bank balance carried into the period; closing = opening + receipts − +// payments (equals the as-of cash balance). +export function receiptsAndPayments(lines: CashEntryLine[], openingBalance = 0): ReceiptsPayments { + const byEntry = new Map() + for (const l of lines) { + const arr = byEntry.get(l.entryId) ?? [] + arr.push(l) + byEntry.set(l.entryId, arr) + } + + const receipts = new Map() + const payments = new Map() + const add = (m: Map, code: string, name: string, amt: number) => { + if (amt <= 0) return + const cur = m.get(code) ?? { name, amount: 0 } + cur.amount += amt + m.set(code, cur) + } + + for (const entryLines of byEntry.values()) { + const cashDelta = entryLines.filter((l) => l.isCashBank).reduce((s, l) => s + l.debit - l.credit, 0) + if (cashDelta === 0) continue + for (const l of entryLines) { + if (l.isCashBank) continue + if (cashDelta > 0) add(receipts, l.accountCode, l.accountName, l.credit - l.debit) + else add(payments, l.accountCode, l.accountName, l.debit - l.credit) + } + } + + const toRows = (m: Map): RpRow[] => + [...m.entries()].map(([code, v]) => ({ code, name: v.name, amount: v.amount })).sort((a, b) => a.code.localeCompare(b.code)) + const receiptRows = toRows(receipts) + const paymentRows = toRows(payments) + const totalReceipts = receiptRows.reduce((s, r) => s + r.amount, 0) + const totalPayments = paymentRows.reduce((s, r) => s + r.amount, 0) + return { + openingBalance, + receipts: receiptRows, + payments: paymentRows, + totalReceipts, + totalPayments, + closingBalance: openingBalance + totalReceipts - totalPayments, + } +}