10 KiB
Add 15% VAT to the supplier-facing Purchase Order PDF
Date: 2026-08-06
Status: Approved (brainstorming complete)
Scope: One component file (src/lib/pdf/purchase-order-document.tsx) + one new test file (src/lib/pdf/purchase-order-document.test.tsx). No PB schema change. No service-layer change.
Context
WFDASH sends a PDF to suppliers when fulfilling a customer order — the
"Purchase Order" PDF rendered by <PurchaseOrderDocument> and served via
/api/v1/purchase-orders/[id]/pdf. The current totals block is just
Subtotal + rule + Total, with an explicit design comment:
No VAT line — we pay what the supplier quotes, no markup math here.
The user has asked that this PDF show a 15% VAT line and a VAT-inclusive
total under the existing Subtotal — same shape as the customer-facing
quote PDF (<QuoteDocument>, which already does this via the
vatAmount() helper in src/lib/pdf/format.ts).
Clarified during brainstorming:
- The change applies to every PO PDF (no per-PO or per-supplier toggle). All current and future POs get the 15% VAT treatment.
- VAT and total are computed at render time. No new PB schema
fields.
purchase_orders.total_amountkeeps its current meaning (the VAT-ex subtotal snapshot) and the route that calls the renderer is unchanged.
Approach (chosen)
Mirror the quote's totals block. Reuse the existing
vatAmount(subtotal) helper from src/lib/pdf/format.ts (already
imported by quote-document.tsx) and copy the exact totals JSX from
the quote (lines 199–213 of quote-document.tsx). One component file
changes. The totalAmount prop is preserved verbatim — it stays the
VAT-ex subtotal snapshot from purchase_orders.total_amount.
Approaches considered and rejected:
- Factor a shared
<TotalsBlock>component. Overkill for ~15 lines of JSX. The two documents' totals blocks differ in one detail (the PO has atotalAmountsnapshot reconciliation that the quote doesn't), so the shared component would need a conditional anyway. - Persist VAT to PB. The user chose compute-at-render. No schema migration is necessary.
Architecture
Single-file surgical change. <PurchaseOrderDocument> is already a
pure function of its props — the change is purely in the body of the
function and the rendered totals <View> block. No new props, no new
data flow.
PurchaseOrderDocument(props)
├─ ...existing JSX (header, table)...
└─ Totals block (REPLACED):
<View style={styles.totals}>
Subtotal → subtotal (unchanged value, unchanged label)
VAT (15%): → vat (NEW row)
────────── (rule, unchanged)
Total: → totalInclVat (was: subtotal/totalAmount)
</View>
Components
<PurchaseOrderDocument> — the only file that changes
File: src/lib/pdf/purchase-order-document.tsx
-
Imports (top of file) — add
vatAmountto the existing import from./format:import { formatZAR, formatDateLong, vatAmount } from "./format"; -
JSDoc update (lines 63–65 in the
Intentionally stripped from this documentlist) — replace the "No VAT line" bullet with:VAT (15%) — added on top of the supplier-quoted subtotal so the printed Total matches what we'll actually pay once VAT lands. Different from the quote (where VAT is added to the customer's selling price); this is the buy-side equivalent.
-
Computations (just after the existing
totalAmountsnapshot reconciliation, ~line 110) — add:// 15% VAT on top of the supplier-quoted subtotal. Uses the same // helper as the quote document so both documents compute VAT // identically. Total = subtotal + vat, rounded to 2dp so the line // matches the printed figures exactly. const vat = vatAmount(subtotal); const totalInclVat = Math.round((subtotal + vat) * 100) / 100;The existing
totalAmountsnapshot reconciliation (theMath.abs(totalAmount - subtotal) < 0.01 ? totalAmount : subtotalline) is kept untouched. It continues to reconcile the VAT-ex subtotal stored onpurchase_orders.total_amount. -
Totals block (replace lines 225–235):
{/* Totals — Subtotal + 15% VAT + rule + Total. Mirrors the quote document so the two PDFs feel like siblings when filed side-by-side. The totalAmount prop continues to hold the VAT-ex subtotal snapshot (purchase_orders.total_amount); the printed Total is the VAT-inclusive figure. */} <View style={styles.totals}> <View style={styles.totalLine}> <Text style={styles.totalLabel}>Subtotal:</Text> <Text style={styles.totalValue}>{formatZAR(subtotal)}</Text> </View> <View style={styles.totalLine}> <Text style={styles.totalLabel}>VAT (15%):</Text> <Text style={styles.totalValue}>{formatZAR(vat)}</Text> </View> <View style={styles.totalRule} /> <View style={styles.totalLine}> <Text style={styles.totalFinalLabel}>Total:</Text> <Text style={styles.totalFinalValue}>{formatZAR(totalInclVat)}</Text> </View> </View>The
styles.totals/totalLine/totalLabel/totalValue/totalRule/totalFinalLabel/totalFinalValueentries are already declared in the same file'sStyleSheet.create({...})block (lines 312–330). No style changes required.
PurchaseOrderDocumentProps interface — unchanged
totalAmount keeps its current meaning (VAT-ex subtotal snapshot).
No prop is added, renamed, or repurposed. This avoids a search-and-
replace blast radius across the service layer
(src/lib/server/purchase-orders-service.ts) and the related route
tests.
Files NOT changing
src/app/api/v1/purchase-orders/[id]/pdf/route.ts— the route already passestotalAmountcorrectly. It does not need to recompute or change anything.src/lib/server/purchase-orders-service.ts—generatePurchaseOrderPdf()passessubtotal(the VAT-ex sum) astotalAmount. That's still correct.src/lib/pdf/format.ts—vatAmount()already exists and is already tested. No change.- Any PB schema / migration —
purchase_orders.total_amountkeeps its current meaning (VAT-ex subtotal).
Data flow
Unchanged at the route/service layer. Inside the component:
subtotal = sum(qty × cost) over items // existing
vat = vatAmount(subtotal) // NEW
totalInclVat = round((subtotal + vat) * 100) / 100 // NEW
The PB record's total_amount continues to hold subtotal (VAT-ex).
The printed PDF shows all three figures.
Error handling
vatAmount(0) === 0— no NaN risk on empty-PO totals.- Negative subtotals (refunded lines) surface as negative
vatand negativetotalInclVat.formatZAR(-N)already handles this cleanly (returnsR -N.NN). - The
totalAmountsnapshot reconciliation (kept untouched) still has its own tolerance:Math.abs(totalAmount - subtotal) < 0.01. Since both sides are VAT-ex, this is unaffected. - No new failure modes introduced.
Testing
Existing tests
src/lib/pdf/render.test.ts— smoke tests for the PDF renderer. These assert on the%PDF-magic bytes and on buffer size (>1024 bytes for a non-trivial PDF). They will continue to pass unchanged because adding a 15% VAT row doesn't affect magic bytes and the new row adds a few hundred bytes (still > 1 KB).src/lib/server/purchase-orders-service.test.tsandsrc/app/api/v1/purchase-orders/[id]/pdf/route.test.ts— checked during exploration. They assert on the route/service behaviour, not on the rendered totals content. Will continue to pass.
New tests
New file: src/lib/pdf/purchase-order-document.test.tsx
(co-located with the component, matching the existing
format.test.ts pattern).
Two test cases:
-
Pure totals computation — call the component in isolation (no PDF render, just inspect the produced React tree or extract the computed values via a small wrapper) and assert that for a known item set the totals are:
- 3 × R 100 = R 300 subtotal
- R 45 VAT (15%)
- R 345 total
-
End-to-end render — call
renderPurchaseOrderPdf()(fromsrc/lib/pdf/render.tsx) with a single item (qty=2, cost=R 50) and assert the returned buffer is a valid PDF (magic bytes + non-trivial size). This mirrors the existingrenderQuotePdfsmoke test inrender.test.tsand confirms the new totals block doesn't break the render pipeline.
A third optional test (skip if it adds noise): assert that the
rendered buffer differs from a "no VAT" render so we know the new
row actually changed the output. Use Math.abs(lengths[i] - lengths[j]) > 0 as a soft assertion that any change occurred.
Live-app verification
Not required. PDF generation is a pure in-process call; the rendering pipeline is unchanged; existing route handlers exercise it on every PO send. Visual confirmation can happen via the operator flow the user already runs.
Out of scope
- Changing the stored
purchase_orders.total_amountfield. - Adding per-PO or per-supplier VAT toggles (user explicitly chose "always add 15% VAT").
- Adding VAT to other supplier-facing documents
(
stock-purchase-document.tsx,stock-purchase-list-document.tsx). These are out of scope unless the user asks for them — the user asked specifically about customer-linked POs. - Changing the customer-facing quote PDF — it already has VAT.
Implementation order
- Edit
src/lib/pdf/purchase-order-document.tsx:- Import
vatAmount - Update JSDoc
- Compute
vatandtotalInclVat - Replace the totals
<View>block
- Import
- Add
src/lib/pdf/purchase-order-document.test.tsxwith the two test cases above. - Run
pnpm test src/lib/pdfto confirm new + existing tests pass. - Run
pnpm buildto confirm no type errors propagate. - (Optional) Manual smoke test: open the buyer-tasks page, click
"Preview" on a PO with known items, confirm the PDF shows
Subtotal/VAT (15%)/Total.