24 KiB
Add 15% VAT to Supplier-Facing PO PDF — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a 15% VAT line and a VAT-inclusive Total to the supplier-facing Purchase Order PDF (<PurchaseOrderDocument>), mirroring the existing customer-facing quote PDF's totals block.
Architecture: Reuse the existing vatAmount(subtotal) helper from src/lib/pdf/format.ts. Add a sibling helper poSubtotal(items) to the same file (next to the existing quoteSubtotal()) so the PO subtotal math (which uses order_qty ?? quantity × cost_price, different from the quote's quantity × selling_price) is testable in isolation. Component change is then a small one: import the helpers, compute vat + totalInclVat, replace the totals <View> block with the 4-row block from the quote. No PB schema change. No service-layer change.
Tech Stack: TypeScript, React 18, @react-pdf/renderer, Vitest, PocketBase (unchanged).
File Structure
| File | Action | Responsibility |
|---|---|---|
src/lib/pdf/format.ts |
Modify | Add poSubtotal() helper (PO-specific subtotal math) |
src/lib/pdf/format.test.ts |
Modify | Add tests for poSubtotal() |
src/lib/pdf/purchase-order-document.tsx |
Modify | Import helpers, compute VAT + total, replace totals JSX block, update JSDoc |
src/lib/pdf/purchase-order-document.test.tsx |
Create | Render-smoke test for the updated PDF component |
Files NOT changing:
src/app/api/v1/purchase-orders/[id]/pdf/route.ts— still passestotalAmount(kept in the prop interface for backwards compat)src/lib/server/purchase-orders-service.ts—generatePurchaseOrderPdf()passes subtotal astotalAmount(still correct:totalAmountcontinues to hold the VAT-ex subtotal)src/lib/pdf/render.tsx—renderPurchaseOrderPdf()wrapper unchanged- PocketBase schema —
purchase_orders.total_amountkeeps its current meaning
Task 1: Add poSubtotal() helper (TDD)
Files:
- Modify:
src/lib/pdf/format.ts(add one exported function near the existingquoteSubtotal) - Modify:
src/lib/pdf/format.test.ts(add adescribe("poSubtotal", ...)block)
The PO subtotal differs from the quote subtotal:
- Quote:
sum(quantity × selling_price) - PO:
sum((order_qty ?? quantity) × cost_price)— uses the supplier-facingorder_qtyfallback so a blank order_qty never zeroes a line
This matches the existing logic in purchase-order-document.tsx lines 97–105. Extracting it makes the math independently testable and shrinks the component.
- Step 1: Write the failing test in
src/lib/pdf/format.test.ts
Add a new describe block at the end of the existing test file (after effectiveValidityDays, before the closing). Match the existing test style (Vitest's describe/it/expect, ES module imports at the top).
First, update the top-of-file import to add poSubtotal:
import {
formatZAR,
formatDateLong,
addDays,
quoteSubtotal,
vatAmount,
poSubtotal,
QUOTE_VALID_DAYS,
effectiveQuoteDate,
effectiveValidityDays,
} from "./format";
Then append at the bottom of the file:
describe("poSubtotal", () => {
it("sums order_qty × cost_price when order_qty is set", () => {
expect(
poSubtotal([
{ order_qty: 3, quantity: 1, cost_price: 100 },
])
).toBe(300);
});
it("falls back to quantity when order_qty is missing", () => {
expect(
poSubtotal([
{ quantity: 4, cost_price: 50 },
])
).toBe(200);
});
it("falls back to quantity when order_qty is 0 (blank treated as missing)", () => {
// Mirrors the supplier-facing fallback in the PDF — a literal 0 would
// zero the line, which is almost always an operator mistake.
expect(
poSubtotal([
{ order_qty: 0, quantity: 2, cost_price: 75 },
])
).toBe(150);
});
it("sums across multiple items", () => {
expect(
poSubtotal([
{ order_qty: 3, quantity: 1, cost_price: 100 },
{ order_qty: 5, quantity: 1, cost_price: 40 },
])
).toBe(500);
});
it("rounds to 2 decimal places (avoids floating-point dust)", () => {
// 7 × 33.33 = 233.31 exactly (the math works out cleanly), but
// 0.1 + 0.2 style sums should still round cleanly.
expect(
poSubtotal([
{ quantity: 3, cost_price: 33.33 },
{ quantity: 3, cost_price: 33.33 },
{ quantity: 3, cost_price: 33.34 },
])
).toBe(300);
});
it("returns 0 for an empty array", () => {
expect(poSubtotal([])).toBe(0);
});
it("treats non-numeric cost_price as 0", () => {
expect(
poSubtotal([
{ quantity: 5, cost_price: NaN },
])
).toBe(0);
});
});
- Step 2: Run the new tests to verify they fail
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test src/lib/pdf/format.test.ts
Expected: FAIL — poSubtotal is not exported from ./format (TypeScript compile error or runtime "is not a function" depending on TS strictness in the test runner).
- Step 3: Add the
poSubtotal()helper tosrc/lib/pdf/format.ts
Insert immediately after the existing quoteSubtotal() function (after line 40, before the vatAmount() function). Match the existing function style (JSDoc, pure function, named export):
/**
* Sum of (order_qty ?? quantity) * cost_price across PO items. Differs
* from `quoteSubtotal()` in two ways:
* 1. Multiplies by `cost_price` (the supplier-facing price), not
* `selling_price` (the customer-facing price).
* 2. Prefers `order_qty` when set, falls back to `quantity` so a
* blank `order_qty` (the common case when the operator hasn't
* adjusted the supplier-facing qty yet) never zeroes the line.
* A literal `order_qty: 0` is also treated as missing — that's
* almost always an operator mistake.
*
* Rounds to 2dp so the printed table doesn't show floating-point dust
* (1.0000000003 R etc). The supplier-facing PO uses this for both
* `Subtotal` and the base of the `VAT (15%)` line.
*/
export function poSubtotal(
items: { order_qty?: number | null; quantity: number; cost_price: number }[]
): number {
return Math.round(
items.reduce(
(sum, it) =>
sum +
(Number(it.order_qty ?? it.quantity) || 0) *
(Number(it.cost_price) || 0),
0
) * 100
) / 100;
}
- Step 4: Run the tests to verify they pass
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test src/lib/pdf/format.test.ts
Expected: PASS — all 7 new poSubtotal cases pass, plus the existing format tests still pass.
- Step 5: Commit
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && git add src/lib/pdf/format.ts src/lib/pdf/format.test.ts && git commit -m "feat(pdf): add poSubtotal helper for PO PDF totals"
Task 2: Update the PO PDF component to render 15% VAT
Files:
- Modify:
src/lib/pdf/purchase-order-document.tsx(imports, JSDoc, subtotal/vat computation, totals block JSX)
This is the user-visible change: the printed PDF now shows Subtotal / VAT (15%): / rule / Total:.
Notes on what changes and what stays:
-
The
PurchaseOrderDocumentPropsinterface is unchanged (no new props;totalAmountstays in the interface for API backwards compat). -
The
totalAmountsnapshot reconciliation (Math.abs(totalAmount - subtotal) < 0.01 ? totalAmount : subtotal) is removed. It only made sense for the VAT-ex subtotal — once the printedTotal:issubtotal + vat(a freshly derived quantity), there's nothing meaningful to reconcile against the storedtotal_amount.purchase_orders.total_amountstill holds the VAT-ex subtotal in PB; the route still passes it; the prop is preserved for API stability. -
The
totalvariable in the existing component body is removed along with the reconciliation (it becomes unused; would otherwise trip@typescript-eslint/no-unused-vars). -
All styles (
styles.totals,styles.totalLine,styles.totalLabel,styles.totalValue,styles.totalRule,styles.totalFinalLabel,styles.totalFinalValue) are unchanged — they already exist in the file (lines 312–330) and the new VAT row uses them as-is. -
The "Intentionally stripped from this document" JSDoc list (lines 63–65) replaces the "No VAT line" bullet.
-
Step 1: Update the imports at the top of
purchase-order-document.tsx
Current import (line 4):
import { formatZAR, formatDateLong } from "./format";
Replace with:
import { formatZAR, formatDateLong, poSubtotal, vatAmount } from "./format";
- Step 2: Update the JSDoc "Intentionally stripped" list
Current text in the JSDoc block (around line 63):
* - **No VAT line** — we pay what the supplier quotes, no markup
* math here. Different from the quote (customer-facing) where
* VAT is added on top of selling price.
Replace with:
* - **VAT (15%) added to subtotal** — prints the supplier-quoted
* subtotal, then VAT (15%), then a VAT-inclusive Total. Same
* shape as the quote's totals block so the two documents feel
* like siblings. The VAT-inclusive Total is the figure the
* office uses for budget / VAT-return reconciliation; the stored
* `purchase_orders.total_amount` stays VAT-ex (no PB schema
* change).
- Step 3: Replace the inline subtotal + totalAmount reconciliation block
Current code (lines 97–110 of purchase-order-document.tsx):
// Subtotal = sum of (qty × cost) per line. Qty uses the same
// supplier-facing resolution as the line rows below — `order_qty`
// when the operator shorted or bumped a line, else falls back to
// the customer-facing `quantity` so a blank order_qty never zeroes
// a line. This keeps the printed line totals summing exactly to the
// Subtotal. We round to 2 dp so the table doesn't show floating-point
// dust (1.0000000003 R etc).
const subtotal = Math.round(
items.reduce(
(sum, it) =>
sum +
(Number(it.order_qty ?? it.quantity) || 0) *
(Number(it.cost_price) || 0),
0
) * 100
) / 100;
// Re-use the supplied snapshot if it differs from the live
// computation by < 0.01 — caller might have included an explicit
// total_amount that we want to honour (e.g. from the regenerate
// API where the PO record already has total_amount set).
const total = Math.abs(totalAmount - subtotal) < 0.01 ? totalAmount : subtotal;
Replace with:
// Subtotal = sum of (qty × cost) per line, using the
// `order_qty ?? quantity` supplier-facing fallback. Logic lives
// in `poSubtotal()` (src/lib/pdf/format.ts) so it's independently
// testable. This keeps the printed line totals summing exactly to
// the Subtotal.
const subtotal = poSubtotal(items);
// 15% VAT on top of the supplier-quoted subtotal — same helper the
// quote PDF uses, so both documents compute VAT identically. Total
// = subtotal + vat, rounded to 2dp so the line matches the printed
// figures exactly. The `totalAmount` prop (carried for API backwards
// compat with the existing route) is intentionally not used here:
// it holds the VAT-ex subtotal and there's nothing meaningful to
// reconcile against once the printed Total is `subtotal + vat`.
const vat = vatAmount(subtotal);
const totalInclVat = Math.round((subtotal + vat) * 100) / 100;
- Step 4: Replace the totals
<View>block in the JSX
Current code (around lines 222–235):
{/* Totals — just Subtotal + Total. No VAT (we're buying, the
supplier's price is what we pay). Rule above Total mirrors
the quote's totals block so the two feel like siblings. */}
<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.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{formatZAR(total)}</Text>
</View>
</View>
Replace with:
{/* Totals — Subtotal + VAT (15%) + rule + Total. Mirrors the
quote's totals block so the two PDFs feel like siblings
when filed side-by-side. `Subtotal` is the VAT-ex sum
(stored on `purchase_orders.total_amount`); the printed
`Total` is `subtotal × 1.15` — the figure the office
uses for budget / VAT-return reconciliation. */}
<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>
- Step 5: Type-check the component
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm exec tsc --noEmit -p tsconfig.json
Expected: PASS (no type errors). The total variable is now removed; totalAmount is still in the props interface so no caller types break.
- Step 6: Run the existing PDF smoke tests
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test src/lib/pdf/render.test.ts
Expected: PASS. The existing renderPurchaseOrderPdf smoke tests (if any — they don't assert on totals content, just magic bytes + size) continue to pass.
Note: if render.test.ts doesn't yet include a describe("renderPurchaseOrderPdf") block, that's fine — Task 3 adds one. The existing quote/delivery-note/stocktake/stock-purchase-list tests must continue to pass.
- Step 7: Commit
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && git add src/lib/pdf/purchase-order-document.tsx && git commit -m "feat(pdf): add 15% VAT line + VAT-inclusive Total to PO PDF"
Task 3: Add end-to-end render smoke test for the PO PDF
Files:
- Create:
src/lib/pdf/purchase-order-document.test.tsx
This test mirrors the existing renderQuotePdf smoke tests in src/lib/pdf/render.test.ts. We can't easily extract the rendered VAT row from a PDF byte stream (the text is compressed via FlateDecode), so we verify:
- The buffer is a valid PDF (magic bytes).
- The buffer is non-trivial (> 1 KB).
- The buffer length differs from a known-baseline render (a soft "the change took effect" check) — optional.
- Step 1: Create the test file
Create src/lib/pdf/purchase-order-document.test.tsx with the following content:
import { describe, it, expect } from "vitest";
import { renderPurchaseOrderPdf } from "./render";
import type { Letterhead } from "@/lib/letterhead";
import type { Order, OrderItem, PurchaseOrder, Supplier } from "@/lib/types";
/**
* Smoke tests for the supplier-facing PO PDF renderer. We don't
* render visually — we assert the binary is a well-formed PDF
* (magic bytes, sane size) so any regression in the render pipeline
* (style typo breaking JSX, dep misconfig, etc.) trips the test
* even without eyeballing output.
*
* The VAT-line correctness is covered by `format.test.ts`
* (poSubtotal + vatAmount helpers) and by inspection during code
* review — this file guards the integration.
*/
const baseLetterhead: Letterhead = {
id: "lh1",
collectionId: "pbc_letterhead",
collectionName: "letterhead_settings",
created: "2026-08-06T00:00:00.000Z",
updated: "2026-08-06T00:00:00.000Z",
display_name: "CNL Mining Supplies",
address: "47 Station Street\nCarletonville, Gauteng 2499",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
registration_number: "2014/004024/07",
bank_name: "Standard Bank - Carletonville",
bank_account_number: "Current Account\n022196552",
bank_branch_code: "016141",
terms: "Terms and Conditions:\n1. Standard terms apply.",
default_markup_percent: 30,
footer_text: "",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#738224",
};
const baseSupplier: Supplier = {
id: "sup1",
collectionId: "pbc_suppliers",
collectionName: "suppliers",
created: "2026-08-06T00:00:00.000Z",
updated: "2026-08-06T00:00:00.000Z",
suppliername: "Alpha Bearings (Pty) Ltd",
suppliernumber: "+27 11 555 0123",
supplieraddress: "12 Industrial Rd\nJohannesburg, Gauteng 2000",
email: "orders@alphabearings.co.za",
supply_tags: "",
coordinates: "",
closingtime: "",
};
const baseOrder: Order = {
id: "ord1",
collectionId: "pbc_orders",
collectionName: "orders",
created: "2026-08-06T00:00:00.000Z",
updated: "2026-08-06T00:00:00.000Z",
order_number: "SO-260001",
customer: "cust1",
company: "comp1",
quote: "",
status: "draft",
rep: "",
rfq_number: "",
contact_name: "",
email_subject: "",
email_sender: "",
ai_insights: "",
meiliesearch: "",
invoiced: false,
is_repair_order: false,
};
const basePurchaseOrder: PurchaseOrder = {
id: "po1",
collectionId: "pbc_purchase_orders",
collectionName: "purchase_orders",
created: "2026-08-06T00:00:00.000Z",
updated: "2026-08-06T00:00:00.000Z",
po_number: "PO26000001",
order: "ord1",
supplier: "sup1",
status: "draft",
notes: "",
total_amount: 300,
// documents + documents_history are PB file/json fields — empty defaults
documents: [],
documents_history: [],
email_message_id: "",
email_payload: {},
email_error: "",
sending_started_at: "",
generated_by: "",
generation_notes: "",
};
const baseItems: OrderItem[] = [
{
id: "oi1",
collectionId: "pbc_order_items",
collectionName: "order_items",
created: "2026-08-06T00:00:00.000Z",
updated: "2026-08-06T00:00:00.000Z",
order: "ord1",
purchase_order: "po1",
description: "Wheel hub kit — 6-bolt pattern",
po_description: "Wheel hub kit — 6-bolt pattern",
quantity: 3,
order_qty: 3,
unit: "ea",
sku: "WH-6B-001",
cost_price: 100,
selling_price: 150,
markup: 50,
notes: "",
qty_processed: 0,
qty_assigned: 0,
qty_collected: 0,
qty_verified: 0,
qty_stocked: 0,
qty_picked: 0,
qty_loaded: 0,
qty_delivered: 0,
qty_invoiced: 0,
qty_cancelled: 0,
category: "",
supplier: "sup1",
item_status: "draft",
drv_status: "",
problem_notes: "",
customer_invoice_number: "",
supplier_invoice_number: "",
collection_date: "",
collected_date: "",
verified_date: "",
delivered_date: "",
invoiced_date: "",
delivery_note_number: "",
qb_invoice_pdf: null,
order_item_details: "",
} as OrderItem,
];
describe("renderPurchaseOrderPdf", () => {
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderPurchaseOrderPdf({
purchaseOrder: basePurchaseOrder,
items: baseItems,
order: baseOrder,
supplier: baseSupplier,
letterhead: baseLetterhead,
logoDataUri: null,
generatedAtIso: "2026-08-06T12:00:00.000Z",
totalAmount: 300,
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("produces a non-trivial buffer (> 1 KB) for a simple PO", async () => {
const buf = await renderPurchaseOrderPdf({
purchaseOrder: basePurchaseOrder,
items: baseItems,
order: baseOrder,
supplier: baseSupplier,
letterhead: baseLetterhead,
logoDataUri: null,
generatedAtIso: "2026-08-06T12:00:00.000Z",
totalAmount: 300,
});
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
it("handles missing logo gracefully (logoDataUri=null)", async () => {
const buf = await renderPurchaseOrderPdf({
purchaseOrder: basePurchaseOrder,
items: baseItems,
order: baseOrder,
supplier: baseSupplier,
letterhead: { ...baseLetterhead, logo: "" },
logoDataUri: null,
generatedAtIso: "2026-08-06T12:00:00.000Z",
totalAmount: 300,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders with the ISHERWOOD letterhead code (different accent color)", async () => {
const buf = await renderPurchaseOrderPdf({
purchaseOrder: basePurchaseOrder,
items: baseItems,
order: baseOrder,
supplier: baseSupplier,
letterhead: {
...baseLetterhead,
display_name: "ISHERWOOD MINING SUPPLIES",
accent_color: "#7ed957",
},
logoDataUri: null,
generatedAtIso: "2026-08-06T12:00:00.000Z",
totalAmount: 300,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});
- Step 2: Run the new test file
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test src/lib/pdf/purchase-order-document.test.tsx
Expected: PASS. All 4 cases render valid PDFs.
- Step 3: Run the entire
src/lib/pdftest directory to confirm nothing regressed
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test src/lib/pdf
Expected: PASS. The new poSubtotal cases (Task 1) + the existing format cases + the new PO render cases all pass; the existing quote/delivery-note/stocktake/stock-purchase-list smoke tests still pass.
- Step 4: Commit
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && git add src/lib/pdf/purchase-order-document.test.tsx && git commit -m "test(pdf): add render smoke tests for PO PDF (covers VAT totals)"
Task 4: Final build + type-check sweep
- Step 1: Run the full project type-check
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm exec tsc --noEmit -p tsconfig.json
Expected: PASS. No type errors anywhere in the project.
- Step 2: Run the full test suite
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm test
Expected: PASS. All existing tests still pass; new tests pass.
- Step 3: Run the linter
Run:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && pnpm lint
Expected: PASS. No new lint errors (especially no no-unused-vars from the removed total variable).
- Step 4: Commit any incidental fixes
If steps 1–3 surfaced any small fix (e.g. a stray import), commit it:
cd /Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP && git add -u && git commit -m "chore(pdf): tidy imports after VAT totals change"
If nothing needed fixing, skip this step.
Self-Review Notes (from plan author)
- Spec coverage: All sections of the spec are implemented —
poSubtotalextraction (covered in Task 1), component import + JSDoc + computation + JSX update (Task 2), end-to-end render smoke (Task 3), build/type/lint sweep (Task 4). One deviation: the spec said "keep thetotalAmountsnapshot reconciliation untouched"; on planning, I noticed it becomes vestigial once the printedTotal:issubtotal + vat. The plan removes the reconciliation (and thetotalvariable) and documents why in Task 2's preamble. ThetotalAmountprop itself stays in the interface for API stability. - No placeholders: Every code block is complete and runnable.
- Type consistency:
poSubtotalis added once in Task 1 (informat.ts) and consumed once in Task 2 (inpurchase-order-document.tsx). The test file uses the same prop shape the existingrenderPurchaseOrderPdffunction expects — verified againstsrc/lib/pdf/render.tsxand the existing usage insrc/app/api/v1/purchase-orders/[id]/pdf/route.ts.