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
51 changes: 51 additions & 0 deletions apps/api/src/reports-statements.route.test.ts
Original file line number Diff line number Diff line change
@@ -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<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('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)
})
})
127 changes: 126 additions & 1 deletion apps/api/src/routes/reports.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +10,9 @@ import {
houseHelp,
houseHelpEntries,
maintenanceTickets,
accounts,
journalEntries,
journalLines,
} from '@opensociety/db'
import {
analyzePayers,
Expand All @@ -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'
Expand Down Expand Up @@ -196,6 +205,122 @@ async function computeVisitorTrends(c: Context<AppEnv>): Promise<VisitorTrends>

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<AppEnv>,
opts: { fromDate?: string; toDate?: string },
): Promise<AccountBalance[]> {
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<number>`coalesce(sum(${journalLines.debit}), 0)`,
credit: sql<number>`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<number>`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) => {
Expand Down
83 changes: 83 additions & 0 deletions apps/api/src/statements.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export * from './finance'
export * from './reconciliation'
export * from './ledger'
export * from './expense'
export * from './statements'
export * from './i18n'
Loading
Loading