feat(pdf): add 15% VAT line + VAT-inclusive Total to PO PDF

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 12:02:55 +02:00
parent a1714833a8
commit 7a6e32cadb
2 changed files with 35 additions and 30 deletions

View file

@ -57,7 +57,7 @@ export function quoteSubtotal(items: { quantity: number; selling_price: number }
* `Subtotal` and the base of the `VAT (15%)` line.
*/
export function poSubtotal(
items: { order_qty?: number | null; quantity: number; cost_price: number }[]
items: { order_qty?: number | null; quantity: number | null; cost_price: number }[]
): number {
return Math.round(
items.reduce(

View file

@ -1,7 +1,7 @@
import { Document, Page, View, Text, Image, StyleSheet } from "@react-pdf/renderer";
import type { Letterhead } from "@/lib/letterhead";
import type { OrderItem, Order, Supplier, PurchaseOrder } from "@/lib/types";
import { formatZAR, formatDateLong } from "./format";
import { formatZAR, formatDateLong, poSubtotal, vatAmount } from "./format";
import { defaultAccentFor } from "@/lib/letterhead";
/**
@ -60,9 +60,13 @@ export interface PurchaseOrderDocumentProps {
* The customer's identity stays internal.
* - **No footer text** — keeps the document visually quiet and
* prevents accidental leakage of branch-specific marketing copy.
* - **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.
* - **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).
*
* Description column uses `po_description` (supplier-facing copy),
* NOT `description` (internal/operator copy). Falling back to
@ -87,27 +91,21 @@ export function PurchaseOrderDocument({
(letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD")
);
// 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;
// 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;
return (
<Document
@ -219,18 +217,25 @@ export function PurchaseOrderDocument({
})}
</View>
{/* 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. */}
{/* 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(total)}</Text>
<Text style={styles.totalFinalValue}>{formatZAR(totalInclVat)}</Text>
</View>
</View>
</Page>