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
93 changes: 90 additions & 3 deletions apps/api/src/routes/apartments.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/routes/bills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/statement.route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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 { 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()) 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 })
})
})
35 changes: 35 additions & 0 deletions apps/api/src/statement.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
45 changes: 45 additions & 0 deletions packages/shared/src/finance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading