WFDASH-WEBAPP/docs/superpowers/specs/2026-08-06-supplier-po-pdf-vat-design.md
SJCoetz a663de2d36 docs(spec): clarify placement of vat computation
Co-authored-by: Orca <help@stably.ai>
2026-08-06 11:45:11 +02:00

10 KiB
Raw Blame History

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:

    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 after the existing totalAmount snapshot 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 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):

    {/* 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.