docs(spec): add 15% VAT to supplier-facing PO PDF
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5053ec96ea
commit
3beedc7ad6
1 changed files with 253 additions and 0 deletions
253
docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md
Normal file
253
docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md
Normal 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`.
|
||||
Loading…
Reference in a new issue