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
80 changes: 80 additions & 0 deletions apps/api/src/lib/statements-pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
import type { Table } from '@opensociety/shared'

// Print-ready PDF of a financial statement (#99). Renders a report Table (its
// first row is the header) as a paginated A4 table: first column left-aligned,
// numeric columns right-aligned. WinAnsi fonts can't encode ₹, so amounts use a
// plain grouped number (the section title names the currency context).
export async function renderTablePdf(opts: { society: string; title: string; meta?: string; table: Table }): Promise<Uint8Array> {
const doc = await PDFDocument.create()
const font = await doc.embedFont(StandardFonts.Helvetica)
const bold = await doc.embedFont(StandardFonts.HelveticaBold)
const grey = rgb(0.4, 0.4, 0.4)

const marginX = 50
const pageW = 595
const pageH = 842
const rows = opts.table.rows
const cols = rows.reduce((m, r) => Math.max(m, r.length), 0)
// First column takes ~45% of the content width; the rest split evenly.
const contentW = pageW - marginX * 2
const firstW = Math.round(contentW * 0.45)
const restW = cols > 1 ? (contentW - firstW) / (cols - 1) : 0
const colRight = (i: number) => (i === 0 ? marginX : marginX + firstW + i * restW)
const colLeft = (i: number) => (i === 0 ? marginX : marginX + firstW + (i - 1) * restW)

const fmt = (v: string | number | null): string => {
if (v === null || v === undefined) return ''
if (typeof v === 'number') return v.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return v
}

let page = doc.addPage([pageW, pageH])
let y = pageH - 50
const newPage = () => {
page = doc.addPage([pageW, pageH])
y = pageH - 50
}
// WinAnsi (the StandardFont encoding) can't encode ₹; fall back to "Rs".
const wa = (s: string): string => s.replace(/₹/g, 'Rs')
const text = (raw: string, x: number, yy: number, size = 9, f = font, color = rgb(0, 0, 0)) =>
page.drawText(wa(raw), { x, y: yy, size, font: f, color })
const rightText = (raw: string, xRight: number, yy: number, size = 9, f = font) => {
const s = wa(raw)
page.drawText(s, { x: xRight - f.widthOfTextAtSize(s, size), y: yy, size, font: f })
}

text(opts.society, marginX, y, 16, bold)
y -= 18
text(opts.title, marginX, y, 12, bold)
y -= 14
if (opts.meta) {
text(opts.meta, marginX, y, 9, font, grey)
y -= 14
}
y -= 6

const drawRow = (row: (string | number | null)[], f = font) => {
for (let i = 0; i < cols; i++) {
const cell = row[i]
if (cell === undefined || cell === null || cell === '') continue
if (typeof cell === 'number') rightText(fmt(cell), colRight(i) + (i === 0 ? firstW : restW), y, 9, f)
else text(fmt(cell), colLeft(i), y, 9, f)
}
y -= 14
}

rows.forEach((row, idx) => {
if (y < 60) {
newPage()
}
if (idx === 0) {
drawRow(row, bold)
page.drawLine({ start: { x: marginX, y: y + 4 }, end: { x: pageW - marginX, y: y + 4 }, thickness: 0.5, color: grey })
} else {
drawRow(row)
}
})

return doc.save()
}
14 changes: 14 additions & 0 deletions apps/api/src/reports-statements.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,18 @@ describe('statutory statement reports — admin only', () => {
setQueue([[RESIDENT]])
expect((await get('/balance-sheet', 'r1')).status).toBe(403)
})

it('serves a CSV download for ?format=csv', async () => {
setQueue([[ADMIN], []]) // auth, then account balances
const res = await get('/trial-balance?format=csv', 'a1')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/csv')
})

it('serves a PDF download for ?format=pdf', async () => {
setQueue([[ADMIN], [], [{ name: 'Green Valley Heights' }]]) // auth, balances, society
const res = await get('/balance-sheet?format=pdf', 'a1')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/pdf')
})
})
39 changes: 31 additions & 8 deletions apps/api/src/routes/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,32 @@ import {
import type { VisitorTrends } from '@opensociety/shared'
import { withDb, withAuth, requireRole } from '../middleware'
import { renderVisitorTrendsPdf } from '../lib/visitor-trends-pdf'
import { renderTablePdf } from '../lib/statements-pdf'
import { tableExport } from '../lib/report-export'
import type { Table } from '@opensociety/shared'
import type { AppEnv } from '../types'

// Export a statement Table as pdf/xlsx/csv per ?format, else null (-> JSON).
async function statementResponse(
c: Context<AppEnv>,
table: Table,
baseName: string,
title: string,
meta?: string,
): Promise<Response | null> {
const format = c.req.query('format')
if (format === 'pdf') {
const [society] = await c.get('db').select({ name: societyConfig.name }).from(societyConfig).limit(1)
const pdf = await renderTablePdf({ society: society?.name ?? 'Society', title, meta, table })
const body = new ArrayBuffer(pdf.byteLength)
new Uint8Array(body).set(pdf)
return new Response(body, {
headers: { 'content-type': 'application/pdf', 'content-disposition': `inline; filename="${baseName}.pdf"` },
})
}
return tableExport(format, table, baseName)
}

export const reportRoutes = new Hono<AppEnv>()
reportRoutes.use('*', withDb)
reportRoutes.use('*', withAuth)
Expand Down Expand Up @@ -241,30 +264,30 @@ async function accountBalances(
return rows.map((r) => ({ ...r, debit: Number(r.debit), credit: Number(r.credit) }))
}

// Trial Balance as of ?toDate (default: all). Nets to zero. ?format=xlsx|csv.
// Trial Balance as of ?toDate (default: all). Nets to zero. ?format=pdf|xlsx|csv.
reportRoutes.get('/trial-balance', async (c) => {
const toDate = c.req.query('toDate')
const tb = trialBalance(await accountBalances(c, { toDate }))
const exp = tableExport(c.req.query('format'), trialBalanceTable(tb), `trial-balance${toDate ? `-${toDate}` : ''}`)
const exp = await statementResponse(c, trialBalanceTable(tb), `trial-balance${toDate ? `-${toDate}` : ''}`, 'Trial Balance', `As of ${toDate ?? 'today'} (amounts in Rs)`)
return exp ?? c.json({ toDate: toDate ?? null, ...tb })
})

// Income & Expenditure (accrual) for ?fromDate..?toDate. ?format=xlsx|csv.
// Income & Expenditure (accrual) for ?fromDate..?toDate. ?format=pdf|xlsx|csv.
reportRoutes.get('/income-expenditure', async (c) => {
const fromDate = c.req.query('fromDate')
const toDate = c.req.query('toDate')
const ie = incomeExpenditure(await accountBalances(c, { fromDate, toDate }))
const exp = tableExport(c.req.query('format'), incomeExpenditureTable(ie), `income-expenditure${toDate ? `-${toDate}` : ''}`)
const exp = await statementResponse(c, incomeExpenditureTable(ie), `income-expenditure${toDate ? `-${toDate}` : ''}`, 'Income & Expenditure', `${fromDate ?? 'start'} to ${toDate ?? 'today'} (amounts in Rs)`)
return exp ?? c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, ...ie })
})

// Balance Sheet as of ?toDate. Current surplus = I&E surplus up to the same date
// so the sheet balances. ?format=xlsx|csv.
// so the sheet balances. ?format=pdf|xlsx|csv.
reportRoutes.get('/balance-sheet', async (c) => {
const toDate = c.req.query('toDate')
const balances = await accountBalances(c, { toDate })
const bs = balanceSheet(balances, incomeExpenditure(balances).surplus)
const exp = tableExport(c.req.query('format'), balanceSheetTable(bs), `balance-sheet${toDate ? `-${toDate}` : ''}`)
const exp = await statementResponse(c, balanceSheetTable(bs), `balance-sheet${toDate ? `-${toDate}` : ''}`, 'Balance Sheet', `As of ${toDate ?? 'today'} (amounts in Rs)`)
return exp ?? c.json({ toDate: toDate ?? null, ...bs })
})

Expand All @@ -273,8 +296,8 @@ reportRoutes.get('/receipts-payments', async (c) => {
const db = c.get('db')
const fromDate = c.req.query('fromDate')
const toDate = c.req.query('toDate')
const respond = (rp: ReturnType<typeof receiptsAndPayments>) => {
const exp = tableExport(c.req.query('format'), receiptsPaymentsTable(rp), `receipts-payments${toDate ? `-${toDate}` : ''}`)
const respond = async (rp: ReturnType<typeof receiptsAndPayments>) => {
const exp = await statementResponse(c, receiptsPaymentsTable(rp), `receipts-payments${toDate ? `-${toDate}` : ''}`, 'Receipts & Payments', `${fromDate ?? 'start'} to ${toDate ?? 'today'} (amounts in Rs)`)
return exp ?? c.json({ fromDate: fromDate ?? null, toDate: toDate ?? null, ...rp })
}

Expand Down
Loading