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.

Phase 3 (basic) + Phase 5 (rest)
Overturned by audit (May 26). Original pick (@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)

OptionVerdict
Playwright PDFPick. 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.
GotenbergContainerized Chromium+LibreOffice microservice. Stateless, horizontally scalable. PDF/A for archival compliance. Worth it if invoice volume justifies infra separation.
pdf-libProgrammatic. Best Unicode/font handling for "overlay on fixed form" use cases.
PuppeteerOlder, slower than Playwright at every benchmark.
@react-pdf/rendererSkip. Memory leaks + Cyrillic bug #1366 = wrong for our case.
pdfkit / pdfmakeSkip 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.

Note: sections below were written for @react-pdf/renderer. The Playwright pattern above replaces them; concepts (batch flow, template structure) remain.

Library pick: @react-pdf/renderer

OptionVerdict
@react-pdf/renderer v4.xPick. Declarative, runs Node + browser, sub-500ms per page, Cyrillic-capable with explicit Font.register, batch-safe.
pdfmakeAuto page-break + repeating header rows are strong. Less ergonomic with React-shared design tokens.
puppeteer2-5s per doc, 200-400MB RAM/Chromium, blows up in containers. Use only if accountants reject React-PDF output.
pdfkitLow-level. Skip.
pnpm --filter @spacehub/api add @react-pdf/renderer qrcode
pnpm --filter @spacehub/workers add @react-pdf/renderer qrcode

Mongolian Cyrillic — gotcha

None of these libs auto-pick a Cyrillic glyph set. Cyrillic text fails silently if a font is registered without explicit format/weight metadata. Known issue: react-pdf #1366.

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

ReportPhaseSource
Invoice (per-bill PDF)Phase 3Spacehub.Module/Reports/InvoiceReport
Statement (per-contract or per-customer)Phase 3v1 multiple
Contract documentPhase 3v1 contract print template
Tenant report (overdue list per owner)Phase 5v1 reports
Property occupancy reportPhase 5v1 PropertyByReport
Revenue report (monthly)Phase 5v1 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

  1. Add Noto Sans Mongolian + Cyrillic fonts to apps/workers/src/pdf/fonts/ (LICENSE OK — SIL OFL).
  2. Build InvoiceDoc, StatementDoc, ContractDoc in Phase 3.
  3. Wire render worker + R2 upload.
  4. API endpoints with on-demand render fallback.
  5. Print-fidelity QA against v1 output (accountants sign off).
  6. Phase 5: remaining reports.

Open questions

Sources