diff --git a/docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md b/docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md new file mode 100644 index 0000000..67c4b0a --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md @@ -0,0 +1,253 @@ +# 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 `` 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 (``, 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_amount` keeps 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 `` component.* Overkill for ~15 lines + of JSX. The two documents' totals blocks differ in one detail (the + PO has a `totalAmount` snapshot 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. `` is already a +pure function of its props — the change is purely in the body of the +function and the rendered totals `` block. No new props, no new +data flow. + +``` +PurchaseOrderDocument(props) + ├─ ...existing JSX (header, table)... + └─ Totals block (REPLACED): + + Subtotal → subtotal (unchanged value, unchanged label) + VAT (15%): → vat (NEW row) + ────────── (rule, unchanged) + Total: → totalInclVat (was: subtotal/totalAmount) + +``` + +## Components + +### `` — the only file that changes + +**File:** `src/lib/pdf/purchase-order-document.tsx` + +1. **Imports** (top of file) — add `vatAmount` to the existing import + from `./format`: + + ```ts + import { formatZAR, formatDateLong, vatAmount } from "./format"; + ``` + +2. **JSDoc update** (lines 63–65 in the `Intentionally stripped from + this document` list) — 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. + +3. **Computations** (just below the existing `subtotal` block, + ~lines 97–110) — add: + + ```ts + // 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 `totalAmount` snapshot reconciliation (the + `Math.abs(totalAmount - subtotal) < 0.01 ? totalAmount : subtotal` + line) is **kept untouched**. It continues to reconcile the + VAT-ex subtotal stored on `purchase_orders.total_amount`. + +4. **Totals block** (replace lines 225–235): + + ```tsx + {/* 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. */} + + + Subtotal: + {formatZAR(subtotal)} + + + VAT (15%): + {formatZAR(vat)} + + + + Total: + {formatZAR(totalInclVat)} + + + ``` + + The `styles.totals` / `totalLine` / `totalLabel` / `totalValue` / + `totalRule` / `totalFinalLabel` / `totalFinalValue` entries are + already declared in the same file's `StyleSheet.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 passes `totalAmount` correctly. It does not need to + recompute or change anything. +- `src/lib/server/purchase-orders-service.ts` — `generatePurchaseOrderPdf()` + passes `subtotal` (the VAT-ex sum) as `totalAmount`. 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_amount` keeps + 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 `vat` and + negative `totalInclVat`. `formatZAR(-N)` already handles this + cleanly (returns `R -N.NN`). +- The `totalAmount` snapshot 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.ts` and + `src/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: + +1. **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 + +2. **End-to-end render** — call `renderPurchaseOrderPdf()` (from + `src/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 existing `renderQuotePdf` + smoke test in `render.test.ts` and 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_amount` field. +- 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 + +1. Edit `src/lib/pdf/purchase-order-document.tsx`: + - Import `vatAmount` + - Update JSDoc + - Compute `vat` and `totalInclVat` + - Replace the totals `` block +2. Add `src/lib/pdf/purchase-order-document.test.tsx` with the two + test cases above. +3. Run `pnpm test src/lib/pdf` to confirm new + existing tests pass. +4. Run `pnpm build` to confirm no type errors propagate. +5. (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`. \ No newline at end of file