Documents API
One endpoint renders every generatable PDF in the system: GET /documents/:type/:entityId.pdf.
It serves every consumer — admin portal, B2B portal, web B2C and mobile. Authentication is uniform, cookie or Bearer, because /documents is a protected prefix. Authorization is not a role or area guard on the route. It is delegated entirely to the registry's per-type access policy, so each document type decides for itself who may fetch it.
The route is mounted at its own top-level /documents prefix, never under /admin.
The Document Registry
apps/api/src/documents/registry.ts maps a document type to a { policy, provider, template } triple. The endpoint reads only this map. Adding a type is one registry entry plus its three files — no endpoint change.
| Type | entityId is | Renders |
|---|---|---|
receipt_b2c | a payments row id | A consumer payment receipt |
corporate_charge | a payments row id | The receipt for one corporate seat charge |
contract_payment | a contract_payments row id | A metadata receipt for an admin-recorded contract payment |
Each part of the triple has one job. The policy is a pure boolean authorization check. The provider loads snapshotted facts, returning null when the entity does not exist. The template turns those facts plus the seller settings into a renderer document model in the requested language.
Providers read written and snapshotted values rather than live-joining mutable rows for financial facts, and never return end-user identity data beyond what the document itself shows.
Access Policies
Two access arms recur across the types.
Internal platform admin — super_admin, or an admin whose assigned areas include b2b. The role check is load-bearing: the external portal role b2b_admin also holds the b2b area, so an area check alone would admit external company staff.
Company member — the requester has a company_users row for the entity's company, as either admin or member. The company is resolved from the entity itself, never from anything the client supplies, so a stale or forged company reference cannot widen access.
| Type | Allowed |
|---|---|
receipt_b2c | The owning end user, a b2b-area platform admin, or any role holding the support area |
corporate_charge | A member of the charged company (resolved via the charge's order), or a b2b-area platform admin |
contract_payment | A member of the contract's company, or a b2b-area platform admin |
The support arm on receipt_b2c needs no role gate, because no external role maps to the support area.
A contract_payment receipt is fetchable by company members even though the underlying payment record is internal-only, because the generated PDF is metadata and carries no proof bytes.
There is no endpoint that reports which document types a caller may access. Discovery is not part of this API — a client either knows the type it wants or gets a 403.
contract_payment is not the uploaded proof file
GET /documents/contract_payment/:contractPaymentId.pdf renders a generated receipt: method, amount, document name, confirmation timestamp, and the contract and company identity. Its provider deliberately never reads document_bytes.
The file an admin actually uploaded when recording the payment is served by a different route, GET /admin/contracts/:id/payments/:paymentId/document, which streams the stored bytes with the uploaded mime type. These are two distinct resources concerning the same payment. An integrator who swaps one for the other gets the wrong bytes.
Request
GET /documents/corporate_charge/d0123456-789a-bcde-f012-890123456789.pdf?lang=ar
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...The .pdf suffix is part of the path, matched by a regex parameter that cannot cross a path segment. It is stripped before the id reaches any policy or provider.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
type | string | A registry key — receipt_b2c, corporate_charge or contract_payment |
entityId | string (uuid) | The entity id for that type, with a .pdf suffix |
Query Parameters
| Parameter | Type | Description |
|---|---|---|
lang | string | en or ar. Any other value is ignored. |
Language Selection
lang wins when it is exactly en or ar. Anything else — absent, empty, fr, ar-SA — falls back to the request's resolved language, which comes from the Accept-Language header. That header resolves to ar when it starts with ar and to en otherwise.
The language selects far more than labels. It picks the Arabic or English legal name from the seller settings, the Arabic or English package name, the localized payment-method label, the date format (Arabic month names with Arabic-Indic digits for ar, pinned to the Gregorian calendar), and the base text direction of the whole page. Monetary amounts always use Western digits, in both languages.
Response
200 OK — the PDF bytes.
Content-Type: application/pdf
Content-Disposition: attachment; filename="receipt-_____.pdf"; filename*=UTF-8''receipt-%D8%B9%D9%85%D8%B1.pdfThe Content-Disposition contract
The header always carries both forms:
filename="..."— an ASCII-safe quoted fallback. Every character outside\x20–\x7eis replaced with_, and quotes, backslashes and semicolons are replaced too so the header cannot be broken.filename*=UTF-8''...— the real name, RFC 5987 percent-encoded. BeyondencodeURIComponent, the characters',(,),*and!are percent-encoded as well.
Clients MUST prefer filename*. Filenames here are derived from facts, and those facts include person and company names that are routinely Arabic. The ASCII fallback for an Arabic name degrades to a row of underscores and is useless as a saved filename — it exists only so the header stays valid for clients that cannot parse RFC 5987.
Forward and back slashes in the derived stem are replaced with - before either form is built.
Filenames per type
| Type | Stem | Fallback chain |
|---|---|---|
receipt_b2c | receipt-<entityId> | None — the default filenamePrefix-<id> form |
corporate_charge | receipt-<personName> | Then the RCPT- receipt number, then the literal charge |
contract_payment | receipt-<companyName> | Then the literal payment — contract payments carry no receipt reference |
A registry entry without a filename hook falls back to <filenamePrefix>-<entityId>. The hook exists so a download carries a human name instead of a raw UUID, matching what the on-screen table already shows.
Error Responses
Checks run in this order:
- Unknown
type→404 DOCUMENT_TYPE_NOT_FOUND entityIdis not a valid UUID →404 DOCUMENT_NOT_FOUND- Policy denies →
403 AUTH_FORBIDDEN - Provider finds no entity →
404 DOCUMENT_NOT_FOUND
| Code | Status | Condition |
|---|---|---|
AUTH_REQUIRED | 401 | Missing token |
AUTH_FORBIDDEN | 403 | The type's policy denied the requester |
DOCUMENT_TYPE_NOT_FOUND | 404 | No such document type in the registry |
DOCUMENT_NOT_FOUND | 404 | entityId is not a UUID, or no such entity |
DOCUMENT_RENDER_FAILED | 500 | The renderer threw |
The non-UUID check runs before the policy's own database lookup on purpose: without it, Postgres's UUID cast fails and the request returns 500 instead of 404.
A denied policy returns a bare false and leaks nothing about whether the entity exists.
Rendering
Rendering is handled by an in-app module at apps/api/src/documents/pdf/, which embeds a font covering Arabic and Latin and implements the Unicode Bidirectional Algorithm so mixed Arabic, Latin and numeric text lays out correctly. See Rendering internals below for the full renderer, fonts and BiDi story.
Document-level content — the seller block, VAT presentation and support footer — comes from Organization Settings. When those settings were never saved the seller block and support footer are omitted and the document still renders.
Rendering internals
The renderer lives at apps/api/src/documents/pdf/, an in-app module rather than a workspace package: renderer.ts (the render loop), bidi.ts (the BiDi layer), fonts.ts (font loading), format.ts (date and amount formatting), types.ts (the document model), and index.ts (the module's entry point). pdfkit and bidi-js are plain dependencies of apps/api.
The module carries no receipt, order or invoice concepts. It turns a DocumentModel — an ordered list of typed Blocks — into a Buffer, so the same renderer serves receipts, quotes, statements and any other document type. Domain meaning lives entirely in apps/api/src/documents/templates/, which builds the model.
Document model
interface DocumentModel {
lang: DocLang; // 'en' | 'ar' — sets base direction and layout mirroring
title?: string; // PDF metadata title
blocks: Block[];
}Align is logical, not physical: 'start' | 'end' | 'center'. start resolves to the right edge in an Arabic document and the left edge in an English one. The whole layout mirrors from lang alone — tables lay their columns out from the leading edge, and a labelValue puts the label on the leading edge and the value on the trailing one.
| Block | Fields | Renders |
|---|---|---|
heading | text, level? (1–3), align? | A heading at 20 / 14 / 11 pt, SemiBold. Wraps. |
labelValue | label, value, strong? | One line: muted 9 pt label on the leading edge, 10 pt value on the trailing edge. strong makes the value SemiBold. |
table | columns, rows | A header row, a divider, then body rows. Each row needs one cell per column. |
divider | — | A thin horizontal rule with 4 pt above and below |
spacer | size? | Vertical whitespace, default 8 pt |
footer | text | Muted 8 pt small print. Wraps. |
slot | size, align? | An outlined empty square of the given side length, reserving space |
TableColumn is { header, align?, flex? }. flex is a relative width weight, defaulting to 1.
Pages are A4 with 50 pt margins on all sides. Blocks are laid out top to bottom against a single cursor. Before drawing any line the renderer checks the remaining height and calls addPage() when it will not fit, so long tables and long paragraphs paginate — there is no repeated table header on a continuation page. Text lines advance by fontSize × 1.5, because Arabic ascenders and descenders need the headroom.
Fonts
Documents use the same type system as the dashboard. Three families ship with the module in apps/api/src/documents/pdf/assets/fonts/, each in Regular and SemiBold: Poppins for English prose, Rubik for Arabic prose, and IBM Plex Mono for numeric and identifier fields. All are OFL-1.1 licensed — the directory carries each family's OFL text and a README recording provenance.
The renderer picks a family per visual run. Arabic-script runs always take Rubik, whatever else applies — Poppins and Plex Mono carry no Arabic glyphs, and PDFKit has no fallback chain, so this rule is what guarantees no missing glyphs. Fields a template marks mono (amounts, references, ICCIDs, ids — the same fields the dashboard renders in font-mono) take Plex Mono. Everything else takes the document language's prose family: Poppins on English pages, Rubik on Arabic pages, including their Latin text. Arabic-Indic dates are prose, so they render in Rubik. No font is loaded from the system. loadFontBuffers() reads the six TTFs once and memoizes them, and the renderer registers every buffer on the PDFKit document before drawing.
BiDi and shaping
PDFKit's fontkit engine does two of the three things Arabic needs. It applies OpenType shaping so Arabic letters join, and for a run it detects as a single RTL script it emits glyphs in visual (reversed) order. What it does not do is run the Unicode Bidirectional Algorithm. It never reorders runs of different direction relative to each other, so a mixed line of Arabic, Latin and numbers comes out in the wrong order.
bidi.ts implements that missing piece — UAX #9 — as an in-app layer over bidi-js.
resolveVisualRuns(text: string, base: Direction): VisualRun[]
interface VisualRun {
text: string; // LOGICAL order — RTL runs are NOT pre-reversed
direction: Direction; // 'ltr' | 'rtl'
level: number; // UAX #9 embedding level, even = LTR, odd = RTL
}The strategy: compute embedding levels with bidi-js, group the line into maximal equal-level runs, then apply UAX #9 rule L2 at run granularity — reversing the positions of runs, never the characters inside them.
Each returned run keeps its text in logical order so the renderer can hand it straight to fontkit, which reverses the RTL ones itself. This reproduces bidi-js's own getReorderedString exactly, because an even-level run undergoes an even number of L2 character reversals (net identity) while an odd-level run undergoes an odd number (net one reversal, which fontkit performs).
Mirrorable characters — parentheses, brackets, angle quotes — at an RTL level are swapped for their mirror glyph in logical order, so they survive fontkit's per-run reversal on the correct side. Callers split on explicit line breaks first: resolveVisualRuns handles a single line.
Two workarounds in renderer.ts sit on top of this so Arabic comes out right:
Spaces inside reversed runs. PDFKit's high-level text renderer collapses the inter-word space glyph inside a reversed RTL glyph run, even though fontkit lays it out with a real advance, so words jam together. Arabic never joins across a space, so each run is split on whitespace and the word tokens are painted individually at measured positions. RTL runs are emitted right to left, with fontkit still shaping and reversing each token internally. Single-token runs are unaffected.
Arabic-Indic digits. fontkit lays Arabic-script glyphs right to left even for a token the BiDi algorithm resolved as left-to-right, and Arabic-Indic digits (U+0660–0669, U+06F0–06F9) are Arabic script — so ١٤ comes out ٤١. Such a token inside an LTR run is pre-reversed, and fontkit's own reversal restores the logical order. Latin digits are Common or Latin script and are untouched. Arabic-Indic digits do not shape contextually, so reversing them is safe.
Word wrapping happens in logical space, before any BiDi resolution, so the greedy fit operates on correctly ordered text.
Formatting helpers
format.ts is a small server-side layer for document authors. packages/i18n is Next-only, so the renderer can't reach for it directly — these helpers mirror the admin app's on-screen conventions instead.
type DocLang = 'en' | 'ar';
halalasToSar(halalas: number): number // integer halalas → major units (halalas/100)
formatMoney(amount: number, currency?: string): string // e.g. '149.00' — 2 decimals, Western digits, every language. Templates render USD or SAR from the payment's currency.
formatSarAmount(sar: number): string // Deprecated wrapper around formatMoney — kept for existing callers
formatDate(date, lang): string // en: 'Jul 14, 2026' ar: '١٤ يوليو ٢٠٢٦'
formatTime(date, lang): string // '14:02' — 24-hour
formatDateTime(date, lang): string // 'Jul 14, 2026 · 14:02'Monetary amounts render in Western digits at two decimals in every language. The currency word is the template's job, not the formatter's. Templates render the payment's currency field to display USD or SAR. Dates and times localize month names and digits by language, using ar-SA or en-US, and are pinned to the Gregorian calendar so financial documents never drift to Hijri on a host with differently configured ICU.
Fixtures
pnpm --filter @activation-sys/api fixtures [outDir]A dev-only visual gate, not part of the build. scripts/render-fixtures.ts renders three throwaway documents that exercise the renderer's hard cases and writes them as PDFs so the Arabic output can be eyeballed. outDir defaults to a directory under the system temp path.