Reports (PDF)
Replaces v1's ReportDataV2 DB-stored XtraReport blobs with HTML/CSS templates rendered to PDF via headless browser. Code-reviewed, diff-able, version-controlled. Mongolian Cyrillic via web fonts — no font-registration dance.
@react-pdf/renderer) is not recommended:
memory leaks in batch generation are unresolved (open issues #2217, #2848, #378, #1130, #718); Cyrillic font rendering is literal open bug #1366 — exactly our case.
New pick: Playwright PDF (HTML+CSS Paged Media → PDF). Templates remain JSX-shaped (React → HTML, then Playwright prints).
Sections below have been rewritten for the new pick.
Library pick: Playwright PDF (or Gotenberg)
| Option | Verdict |
|---|---|
| Playwright PDF | Pick. Faster than Puppeteer (147ms→42ms cold, 48ms→3ms warm). Better-maintained API. HTML+CSS web fonts trivially handle Cyrillic. 200-500MB RAM per instance — recycle every N PDFs. |
| Gotenberg | Containerized Chromium+LibreOffice microservice. Stateless, horizontally scalable. PDF/A for archival compliance. Worth it if invoice volume justifies infra separation. |
| pdf-lib | Programmatic. Best Unicode/font handling for "overlay on fixed form" use cases. |
| Puppeteer | Older, slower than Playwright at every benchmark. |
| @react-pdf/renderer | Skip. Memory leaks + Cyrillic bug #1366 = wrong for our case. |
| pdfkit / pdfmake | Skip for invoices. Lower-level than needed. |
pnpm --filter @spacehub/workers add playwright @react-email/render
# install Chromium once on each worker host:
pnpm playwright install chromium
Mongolian Cyrillic — solved via web fonts
HTML+CSS web fonts (Noto Sans, served from files.spacehub.mn or bundled with worker) handle Cyrillic with no font-registration ceremony:
<style>
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
font-weight: 400;
}
body { font-family: 'Noto Sans', sans-serif; }
</style>
Render flow with Playwright
// apps/workers/src/pdf.ts
import { chromium, type Browser } from "playwright";
import { renderToStaticMarkup } from "react-dom/server";
import { InvoiceDoc } from "./pdf/invoice/InvoiceDoc";
import { putToR2 } from "@spacehub/integrations/storage";
let browser: Browser | undefined;
async function getBrowser() {
if (!browser) browser = await chromium.launch({ args: ["--no-sandbox"] });
return browser;
}
export async function renderInvoicePdf(invoiceId: string) {
const inv = await db.query.invoices.findFirst({
where: eq(invoices.id, invoiceId),
with: { items: true, owner: true },
});
if (!inv) throw new Error("not found");
const html = `<!doctype html><html><head>${headStyles}</head><body>${
renderToStaticMarkup(<InvoiceDoc invoice={inv} />)
}</body></html>`;
const browser = await getBrowser();
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
const pdf = await page.pdf({ format: "A4", printBackground: true, margin: { top: 36, bottom: 36, left: 36, right: 36 } });
await ctx.close();
const url = await putToR2(`invoices/${inv.ownerId}/${inv.id}.pdf`, pdf, "application/pdf");
await db.update(invoices).set({ pdfUrl: url }).where(eq(invoices.id, inv.id));
}
// Recycle browser every N renders to avoid memory creep
let renderCount = 0;
export async function maybeRecycleBrowser() {
if (++renderCount > 500 && browser) {
await browser.close();
browser = undefined;
renderCount = 0;
}
}
Template — JSX renders to HTML, not PDF primitives
Components remain ergonomic React. No more <View>/<Text> primitives:
// apps/workers/src/pdf/invoice/InvoiceDoc.tsx
export function InvoiceDoc({ invoice }: { invoice: InvoiceWithItems }) {
return (
<div className="invoice">
<header className="invoice-header">
<div>
<h1>{invoice.owner.name}</h1>
<p className="meta">РД: {invoice.owner.tinNumber}</p>
</div>
<div className="meta-right">
<h2>Нэхэмжлэх #{invoice.code}</h2>
<p className="meta">Огноо: {format(invoice.issueDate)}</p>
</div>
</header>
<table>{/* line items */}</table>
<footer>
{invoice.ebarimtQrData && (
<div className="qr-block">
<img src={`data:image/png;base64,${invoice.ebarimtQrData}`} />
<span>Сугалааны дугаар: {invoice.ebarimtLottery}</span>
</div>
)}
</footer>
</div>
);
}
CSS uses @page + page-break-* for multi-page invoices. Standard print CSS — fully testable in a browser.
Library pick: @react-pdf/renderer
| Option | Verdict |
|---|---|
@react-pdf/renderer v4.x | Pick. Declarative, runs Node + browser, sub-500ms per page, Cyrillic-capable with explicit Font.register, batch-safe. |
pdfmake | Auto page-break + repeating header rows are strong. Less ergonomic with React-shared design tokens. |
puppeteer | 2-5s per doc, 200-400MB RAM/Chromium, blows up in containers. Use only if accountants reject React-PDF output. |
pdfkit | Low-level. Skip. |
pnpm --filter @spacehub/api add @react-pdf/renderer qrcode
pnpm --filter @spacehub/workers add @react-pdf/renderer qrcode
Mongolian Cyrillic — gotcha
Register Noto Sans (Cyrillic + Latin) at boot:
// apps/workers/src/pdf/font.ts
import path from "node:path";
import { Font } from "@react-pdf/renderer";
const fontsDir = path.join(__dirname, "fonts");
Font.register({
family: "NotoSans",
fonts: [
{ src: path.join(fontsDir, "NotoSans-Regular.ttf") },
{ src: path.join(fontsDir, "NotoSans-Bold.ttf"), fontWeight: "bold" },
{ src: path.join(fontsDir, "NotoSans-Italic.ttf"), fontStyle: "italic" },
],
});
// register once on module load
export const FONT_FAMILY = "NotoSans";
Report catalogue
| Report | Phase | Source |
|---|---|---|
| Invoice (per-bill PDF) | Phase 3 | Spacehub.Module/Reports/InvoiceReport |
| Statement (per-contract or per-customer) | Phase 3 | v1 multiple |
| Contract document | Phase 3 | v1 contract print template |
| Tenant report (overdue list per owner) | Phase 5 | v1 reports |
| Property occupancy report | Phase 5 | v1 PropertyByReport |
| Revenue report (monthly) | Phase 5 | v1 AmountToTextFunction |
Template structure
// apps/workers/src/pdf/invoice/InvoiceDoc.tsx
import { Document, Page, View, Text, Image, StyleSheet } from "@react-pdf/renderer";
import { FONT_FAMILY } from "../font";
import type { InvoiceWithItems } from "@spacehub/db/types";
const styles = StyleSheet.create({
page: { fontFamily: FONT_FAMILY, fontSize: 10, padding: 36, color: "#111" },
header: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
brand: { fontSize: 16, fontWeight: "bold" },
meta: { fontSize: 9, color: "#666" },
table: { marginTop: 16, borderTop: "1pt solid #ddd" },
row: { flexDirection: "row", padding: 6, borderBottom: "0.5pt solid #eee" },
rowHead: { backgroundColor: "#f6f7fb", fontWeight: "bold" },
cellDesc:{ flex: 4 }, cellQty: { flex: 1, textAlign: "right" },
cellUnit:{ flex: 2, textAlign: "right" }, cellTot: { flex: 2, textAlign: "right" },
totals: { marginTop: 16, alignSelf: "flex-end", width: 220 },
totRow: { flexDirection: "row", justifyContent: "space-between", padding: 3 },
grand: { fontWeight: "bold", fontSize: 13, borderTop: "1pt solid #111", paddingTop: 6 },
footer: { marginTop: 30, flexDirection: "row", gap: 16, alignItems: "center" },
qr: { width: 90, height: 90 },
lottery: { fontSize: 9 },
});
export function InvoiceDoc({ invoice, qrPng }: { invoice: InvoiceWithItems; qrPng: Buffer }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.brand}>{invoice.owner.name}</Text>
<Text style={styles.meta}>РД: {invoice.owner.tinNumber}</Text>
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={{ fontSize: 14, fontWeight: "bold" }}>Нэхэмжлэх #{invoice.code}</Text>
<Text style={styles.meta}>Огноо: {format(invoice.issueDate)}</Text>
</View>
</View>
<View style={styles.table}>
<View style={[styles.row, styles.rowHead]}>
<Text style={styles.cellDesc}>Бараа / үйлчилгээ</Text>
<Text style={styles.cellQty}>Тоо</Text>
<Text style={styles.cellUnit}>Нэгж үнэ</Text>
<Text style={styles.cellTot}>Дүн</Text>
</View>
{invoice.items.map(it => (
<View key={it.id} style={styles.row}>
<Text style={styles.cellDesc}>{it.description}</Text>
<Text style={styles.cellQty}>{it.quantity}</Text>
<Text style={styles.cellUnit}>{formatMNT(it.unitPrice)}</Text>
<Text style={styles.cellTot}>{formatMNT(it.amount)}</Text>
</View>
))}
</View>
<View style={styles.totals}>
<View style={styles.totRow}><Text>Дүн</Text><Text>{formatMNT(invoice.subtotal)}</Text></View>
<View style={styles.totRow}><Text>НӨАТ</Text><Text>{formatMNT(invoice.vatAmount)}</Text></View>
<View style={[styles.totRow, styles.grand]}><Text>Нийт</Text><Text>{formatMNT(invoice.total)}</Text></View>
</View>
{invoice.ebarimtQrData && (
<View style={styles.footer}>
<Image src={qrPng} style={styles.qr} />
<View>
<Text style={styles.lottery}>Сугалааны дугаар: {invoice.ebarimtLottery}</Text>
<Text style={styles.meta}>eBarimt баталгаажуулсан</Text>
</View>
</View>
)}
</Page>
</Document>
);
}
Render flow
// apps/workers/src/pdf.ts
import { renderToBuffer } from "@react-pdf/renderer";
import QRCode from "qrcode";
import { InvoiceDoc } from "./pdf/invoice/InvoiceDoc";
import { putToR2 } from "@spacehub/integrations/storage";
export async function renderInvoicePdf(invoiceId: string) {
const inv = await db.query.invoices.findFirst({
where: eq(invoices.id, invoiceId),
with: { items: true, owner: true },
});
if (!inv) throw new Error("not found");
const qrPng = inv.ebarimtQrData
? await QRCode.toBuffer(inv.ebarimtQrData, { type: "png", margin: 1, width: 240 })
: Buffer.alloc(0);
const pdf = await renderToBuffer(<InvoiceDoc invoice={inv} qrPng={qrPng} />);
const url = await putToR2(`invoices/${inv.ownerId}/${inv.id}.pdf`, pdf, "application/pdf");
await db.update(invoices).set({ pdfUrl: url }).where(eq(invoices.id, inv.id));
}
Batch generation
BullMQ pdf queue with concurrency 4-8 per worker. FlowProducer: one parent generate-package, one child render-invoice per invoice. Parent only completes when all children done.
API surface
GET /v2/invoices/{id}/pdf # 307 → R2 url, renders via queue if missing
GET /v2/statements/{contractId}.pdf
GET /v2/contracts/{id}.pdf
POST /v2/reports/render # admin: ad-hoc report runs
Build steps
- Add Noto Sans Mongolian + Cyrillic fonts to
apps/workers/src/pdf/fonts/(LICENSE OK — SIL OFL). - Build
InvoiceDoc,StatementDoc,ContractDocin Phase 3. - Wire render worker + R2 upload.
- API endpoints with on-demand render fallback.
- Print-fidelity QA against v1 output (accountants sign off).
- Phase 5: remaining reports.
Open questions
- Pixel-perfect parity with v1? If yes, allocate buffer time. If "close enough" works, save 1-2 wks.
- Branding per owner (logo + colors)? Recommend: yes, simple — logo from
property_owners.logoUrl, accent color fromproperty_owners.brandColor. - Multi-page table headers: react-pdf has
fixedview prop for repeating headers. Test with 100+ line invoices.