diff --git a/apps/api/src/lib/statements-pdf.ts b/apps/api/src/lib/statements-pdf.ts new file mode 100644 index 0000000..46f3c82 --- /dev/null +++ b/apps/api/src/lib/statements-pdf.ts @@ -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 { + 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() +} diff --git a/apps/api/src/reports-statements.route.test.ts b/apps/api/src/reports-statements.route.test.ts index 7ca7727..803f0a1 100644 --- a/apps/api/src/reports-statements.route.test.ts +++ b/apps/api/src/reports-statements.route.test.ts @@ -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') + }) }) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 86c5c85..7a5f639 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -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, + table: Table, + baseName: string, + title: string, + meta?: string, +): Promise { + 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() reportRoutes.use('*', withDb) reportRoutes.use('*', withAuth) @@ -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 }) }) @@ -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) => { - const exp = tableExport(c.req.query('format'), receiptsPaymentsTable(rp), `receipts-payments${toDate ? `-${toDate}` : ''}`) + const respond = async (rp: ReturnType) => { + 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 }) }