docs(spec): add 15% VAT to supplier-facing PO PDF

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 11:44:54 +02:00
parent 5053ec96ea
commit 3beedc7ad6

View file

@ -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 `<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_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 `<TotalsBlock>` 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. `<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`
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. */}
<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` / `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 `<View>` 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`.