diff --git a/docs/superpowers/plans/2026-11-05-purchase-order-from-quote.md b/docs/superpowers/plans/2026-11-05-purchase-order-from-quote.md new file mode 100644 index 0000000..ca7babe --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-purchase-order-from-quote.md @@ -0,0 +1,3253 @@ +# Purchase orders raised from a supplier quote — 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:** Raise a purchase order in Mega Plant from a supplier quote that AI reads, with a person reviewing every line before it becomes a PO — then email it to the supplier and attach the invoice to it later. + +**Architecture:** The quote is uploaded to a server route, sent to MiniMax (PDF on the Anthropic-compatible route, images on the OpenAI-compatible route), and comes back as a JSON **draft**. A draft is never money: it is shown in an editable form, and the person confirms it. The PO is then written to the existing `purchases` / `purchase_lines` collections, so it rolls up into the running cost exactly like an imported ERP PO. The PO document is generated on demand from the saved record, and the email hand-off downloads that document and opens a pre-filled draft. + +**Tech Stack:** Next.js 16 (App Router, webpack mode), React 19, TypeScript, PocketBase (`pocketbase` SDK), `pdf-lib` (the PO document), `pdfjs-dist` (reading the generated PDF back in a test), `node --test` + `tsx` (the test runner), Tailwind v4. + +**Spec:** `docs/superpowers/specs/2026-11-05-purchase-order-from-quote-design.md` + +## Global Constraints + +- **The PO's totals are DERIVED from the reviewed lines, never copied from the quote.** The server recomputes them on save and ignores any total the client sends. +- **The quote's stated total is kept** on the PO as `quote_total` and **compared** to the computed total. A difference over R0,50 is shown in amber. +- **A cost code never guesses a machine.** A line with no machine is saved with an EMPTY `asset` and reported. Never a neighbour, never a zero. +- **A PO number already on file is refused (409).** The same spend booked twice is worse than an inconvenience. +- **The PO number is `MEGPPO-0001`** — prefix, hyphen, four digits, one global counter. The ERP series (`MEGPPO49676`) must never be generated by this system. +- **The AI key lives in the database**, not in the environment. `app_settings`, superuser rules, the value field `hidden`. +- **The API key never reaches the browser.** The Settings UI shows the last four characters only. +- **The AI proposes; a person disposes.** Nothing is written until the person saves, and a quote that cannot be read produces an error, never a half-written PO. +- **Every write route is Full-user only** — `refuseUnlessFull()` from `lib/require-full.ts`. +- **Every write to `purchases` revalidates** `/running/purchases`, `/running` and `/expenses/suppliers`. +- **The mobile checklist is the standing rule** (AGENTS.md, Round 4): no horizontal page scroll at 375px, no text under 12px, no tap target under 44×44px, wide tables scroll inside their own container with the Description column sticky on an OPAQUE background. +- **Never log the API key**, and never put it in a response body, an error message or a URL. +- **Typecheck is clean before every commit.** `npm run typecheck` (this project has a clean baseline — any error is yours). + +--- + +## File structure + +**Created** + +| File | Responsibility | +|---|---| +| `lib/quote-draft.ts` | The pure draft maths: cleaning, totals, reconciliation, the PO number series. No I/O, no React. | +| `lib/company.ts` | Mega Plant's own details as the ERP prints them, plus the payment-terms label. One place to correct them. | +| `lib/purchase-order-email.ts` | The mailto: link and the body it carries. | +| `lib/app-settings.ts` | Server-only read/write of `app_settings`, and the key mask. | +| `lib/quote-reader.ts` | Server-only: the MiniMax call, the JSON it returns, and the draft it becomes. | +| `lib/purchase-order-pdf.ts` | The PO document, generated with pdf-lib from a saved PO. | +| `components/PurchaseOrderComposer.tsx` | The composer: upload → AI read → review → save. | +| `components/AiSettings.tsx` | The Settings section for the AI key. | +| `app/api/purchases/quote/read/route.ts` | POST — read a quote with AI. | +| `app/api/purchases/route.ts` | POST — raise a PO from an approved draft. | +| `app/api/purchases/[id]/document/route.ts` | GET — the PO document (open or download). | +| `app/api/settings/ai/route.ts` | GET / PUT — the AI settings. | +| `app/api/settings/ai/test/route.ts` | POST — test the connection. | +| `scripts/setup-app-settings-collection.mjs` | Creates `app_settings`. | +| `lib/quote-draft.test.ts` | Tests for the draft maths. | +| `lib/purchase-order-email.test.ts` | Tests for the mailto: and the body. | +| `lib/app-settings.test.ts` | Tests for the key mask. | +| `lib/quote-reader.test.ts` | Tests for the JSON extraction and the draft mapping. | +| `lib/purchase-order-pdf.test.ts` | Test that reads the generated PDF back and checks its text. | + +**Modified** + +| File | Change | +|---|---| +| `scripts/setup-purchase-collections.mjs` | Eight new `purchases` fields. | +| `scripts/setup-suppliers-collection.mjs` | `email` on `suppliers`. | +| `lib/data.ts` | The new `Purchase` fields. | +| `components/PurchaseIntake.tsx` | The "Create purchase order" button, the per-PO document/email actions, the origin badge. | +| `components/MachineCombobox.tsx` | An `onChange` so a machine can be picked. | +| `app/(dashboard)/running/purchases/page.tsx` | Pass the machines, the suppliers and the session user into the composer. | +| `app/(dashboard)/settings/page.tsx` | Mount the AI settings section. | +| `components/Suppliers2View.tsx` | The supplier's email, editable. | +| `lib/supplier-spend.ts`, `lib/supplier-spend-data.ts` | Carry the supplier's email through. | +| `AGENTS.md` | The new rules. | + +--- + +### Task 1: The draft maths + +**Files:** +- Create: `lib/quote-draft.ts` +- Test: `lib/quote-draft.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `cleanNumber(raw: unknown): number | null` + - `cleanDate(raw: unknown): string` + - `round2(n: number): number` + - `type DraftLine = { description: string; qty: number | null; unit: string; rate: number | null; discount_pct: number | null; vat_pct: number | null; assetId: string; costCode: string; materialCode: string }` + - `type QuoteDraft = { supplierName: string; supplierCode: string; supplierEmail: string; quoteNo: string; quoteDate: string; currency: string; pricesIncludeVat: boolean; lines: DraftLine[]; statedSubtotal: number | null; statedVat: number | null; statedTotal: number | null; confidence: "high" | "medium" | "low"; notes: string[] }` + - `type DraftTotals = { subtotal: number; vat: number; total: number }` + - `DEFAULT_VAT_PCT`, `PO_NUMBER_PREFIX`, `REQ_NUMBER_PREFIX`, `PO_NUMBER_SEPARATOR`, `PO_NUMBER_DIGITS` + - `grossAmount(line: DraftLine): number` + - `lineExcl(line: DraftLine, pricesIncludeVat: boolean): number` + - `lineVat(line: DraftLine, pricesIncludeVat: boolean): number` + - `computeTotals(lines: DraftLine[], pricesIncludeVat: boolean): DraftTotals` + - `reconcile(computed: DraftTotals, statedTotal: number | null): { difference: number | null; matches: boolean }` + - `nextOrderNumbers(existingPoNumbers: string[]): { po_no: string; req_no: string }` + - `emptyLine(): DraftLine` + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/quote-draft.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + cleanDate, cleanNumber, computeTotals, emptyLine, grossAmount, lineExcl, lineVat, + nextOrderNumbers, reconcile, round2, type DraftLine, +} from "./quote-draft.ts"; + +const line = (over: Partial = {}): DraftLine => ({ ...emptyLine(), ...over }); + +test("cleanNumber reads the ways a document writes a number", () => { + assert.equal(cleanNumber("1 234.50"), 1234.5); + assert.equal(cleanNumber("1,234.50"), 1234.5); + assert.equal(cleanNumber("R 1 234.50"), 1234.5); + assert.equal(cleanNumber(11940), 11940); + assert.equal(cleanNumber("(12.00)"), -12); + assert.equal(cleanNumber(""), null); + assert.equal(cleanNumber("no rate"), null); + assert.equal(cleanNumber(undefined), null); +}); + +test("cleanDate reads the ways a document writes a date", () => { + assert.equal(cleanDate("07/10/2026"), "2026-10-07"); + assert.equal(cleanDate("7/10/2026"), "2026-10-07"); + assert.equal(cleanDate("2026-10-07"), "2026-10-07"); + assert.equal(cleanDate("07.10.2026"), "2026-10-07"); + assert.equal(cleanDate("27 Oct 2026"), "2026-10-27"); + assert.equal(cleanDate("27 October 2026"), "2026-10-27"); + assert.equal(cleanDate("whenever"), ""); + assert.equal(cleanDate(""), ""); +}); + +test("round2 keeps two decimals without floating dust", () => { + assert.equal(round2(0.1 + 0.2), 0.3); + assert.equal(round2(11940.005), 11940.01); +}); + +test("a line with no rate is worth nothing, and says so", () => { + const l = line({ description: "NOZZLES", rate: null }); + assert.equal(grossAmount(l), 0); + assert.equal(lineExcl(l, false), 0); +}); + +test("an exclusive price adds VAT on top", () => { + const l = line({ qty: 1, rate: 11940, vat_pct: 15 }); + assert.equal(lineExcl(l, false), 11940); + assert.equal(lineVat(l, false), 1791); +}); + +test("an inclusive price carries the VAT inside it", () => { + const l = line({ qty: 1, rate: 13731, vat_pct: 15 }); + assert.equal(lineExcl(l, true), 11940); + assert.equal(lineVat(l, true), 1791); +}); + +test("a discount comes off before the VAT is taken", () => { + const l = line({ qty: 4, rate: 1201.2, discount_pct: 15, vat_pct: 15 }); + // 4 x 1201.20 = 4804.80, less 15% = 4084.08 + assert.equal(grossAmount(l), 4084.08); + assert.equal(lineExcl(l, false), 4084.08); + assert.equal(lineVat(l, false), 612.61); +}); + +test("the totals come from the lines", () => { + const lines = [ + line({ description: "4 HAUL INJECTORS", qty: 4, rate: 425, vat_pct: 15 }), + line({ description: "NOZZLES", qty: 4, rate: 1201.2, discount_pct: 15, vat_pct: 15 }), + line({ description: "WASHER KIT", qty: 1, rate: 338, vat_pct: 15 }), + ]; + const t = computeTotals(lines, false); + assert.equal(t.subtotal, 6122.08); + assert.equal(t.vat, 918.31); + assert.equal(t.total, 7040.39); +}); + +test("a quote that matches the lines reconciles", () => { + const t = computeTotals([line({ qty: 1, rate: 100, vat_pct: 15 })], false); + assert.deepEqual(reconcile(t, 115), { difference: 0, matches: true }); +}); + +test("a quote that does not match the lines says by how much", () => { + const t = computeTotals([line({ qty: 1, rate: 100, vat_pct: 15 })], false); + const r = reconcile(t, 120); + assert.equal(r.matches, false); + assert.equal(r.difference, -5); +}); + +test("a quote with no stated total is unknown, not zero", () => { + const t = computeTotals([line({ qty: 1, rate: 100, vat_pct: 15 })], false); + assert.deepEqual(reconcile(t, null), { difference: null, matches: false }); +}); + +test("the PO number is MEGPPO-0001 and never an ERP number", () => { + assert.deepEqual(nextOrderNumbers([]), { po_no: "MEGPPO-0001", req_no: "MEGPREQ-0001" }); + assert.deepEqual(nextOrderNumbers(["MEGPPO-0001"]), { po_no: "MEGPPO-0002", req_no: "MEGPREQ-0002" }); +}); + +test("the ERP's own numbers are ignored by the counter", () => { + const n = nextOrderNumbers(["MEGPPO49676", "MEGPPO50532", "MEGPPO49421", "MEGPPO50027"]); + assert.equal(n.po_no, "MEGPPO-0001"); +}); + +test("the counter survives a gap", () => { + assert.equal(nextOrderNumbers(["MEGPPO-0001", "MEGPPO-0007"]).po_no, "MEGPPO-0008"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --test lib/quote-draft.test.ts` +Expected: FAIL — "Cannot find module './quote-draft.ts'" + +- [ ] **Step 3: Write the implementation** + +```ts +// lib/quote-draft.ts +/** + * The draft a supplier quote becomes. + * + * PURE. No I/O, no React, no PocketBase — so the arithmetic can be tested on its + * own and the same rules run on the server and in the browser. + * + * The one rule that matters: **the totals are computed from the lines, never + * copied from the document.** A quote that says R 48 074,66 is a claim; the + * lines a person approves are the money. + */ + +/** VAT used when a document does not say. Mega's own rate. */ +export const DEFAULT_VAT_PCT = 15; + +export const PO_NUMBER_PREFIX = "MEGPPO"; +export const REQ_NUMBER_PREFIX = "MEGPREQ"; +export const PO_NUMBER_SEPARATOR = "-"; +export const PO_NUMBER_DIGITS = 4; + +/** One line of the draft a person is about to approve. */ +export type DraftLine = { + description: string; + qty: number | null; + unit: string; + rate: number | null; + discount_pct: number | null; + vat_pct: number | null; + /** The machine the line is booked to. EMPTY means unallocated, which is a real state. */ + assetId: string; + /** The machine's code, exactly as the register writes it. */ + costCode: string; + materialCode: string; +}; + +export type QuoteDraft = { + supplierName: string; + supplierCode: string; + supplierEmail: string; + quoteNo: string; + quoteDate: string; + currency: string; + /** TRUE when the document's unit prices already carry VAT. */ + pricesIncludeVat: boolean; + lines: DraftLine[]; + /** What the document said. Kept for the record, never used as the PO's total. */ + statedSubtotal: number | null; + statedVat: number | null; + statedTotal: number | null; + confidence: "high" | "medium" | "low"; + notes: string[]; +}; + +export type DraftTotals = { subtotal: number; vat: number; total: number }; + +/** Two decimals, without the floating dust. */ +export function round2(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + +/** + * A number written the ways documents write numbers. + * + * "1 234.50", "1,234.50", "R 1 234.50" and "(12.00)" all mean something. Text + * that is not a number is `null` — unknown, never zero. + */ +export function cleanNumber(raw: unknown): number | null { + if (raw === null || raw === undefined) return null; + if (typeof raw === "number") return Number.isFinite(raw) ? raw : null; + let t = String(raw).trim(); + if (!t) return null; + t = t.replace(/[R\s]/gi, "").replace(/,/g, ""); + const negative = /^\(.*\)$/.test(t); + if (negative) t = t.slice(1, -1); + if (!/^-?\d+(\.\d+)?$/.test(t)) return null; + const n = Number(t); + if (!Number.isFinite(n)) return null; + return negative ? -n : n; +} + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** + * A date written the ways documents write dates. South African documents use + * dd/mm/yyyy; an AI reads them and may hand back any of these. + * + * An unreadable date is "" — unknown, never today's date. + */ +export function cleanDate(raw: unknown): string { + if (raw === null || raw === undefined) return ""; + const t = String(raw).trim(); + if (!t) return ""; + let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t); + if (m) return `${m[1]}-${m[2]}-${m[3]}`; + m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(t); + if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`; + m = /^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})$/.exec(t); + if (m) { + const mi = MONTHS.indexOf(m[2].slice(0, 3).toLowerCase()); + if (mi >= 0) return `${m[3]}-${String(mi + 1).padStart(2, "0")}-${m[1].padStart(2, "0")}`; + } + return ""; +} + +/** A blank line, ready to be filled in. */ +export function emptyLine(): DraftLine { + return { + description: "", qty: null, unit: "", rate: null, discount_pct: null, + vat_pct: DEFAULT_VAT_PCT, assetId: "", costCode: "", materialCode: "", + }; +} + +/** qty × rate, less the discount. The money BEFORE the VAT question. */ +export function grossAmount(line: DraftLine): number { + const qty = line.qty ?? 0; + const rate = line.rate ?? 0; + let amount = qty * rate; + const disc = line.discount_pct ?? 0; + if (disc) amount -= amount * (disc / 100); + return round2(amount); +} + +/** + * The line's value EXCLUDING VAT. + * + * `pricesIncludeVat` decides whether the rate the document quotes already has + * VAT inside it. "R 1 201.20 incl VAT" and "R 1 201.20" are the same number and + * different money, and a supplier quote may say either. + */ +export function lineExcl(line: DraftLine, pricesIncludeVat: boolean): number { + const gross = grossAmount(line); + if (!pricesIncludeVat) return gross; + const pct = (line.vat_pct ?? DEFAULT_VAT_PCT) / 100; + return round2(gross / (1 + pct)); +} + +/** The line's VAT. */ +export function lineVat(line: DraftLine, pricesIncludeVat: boolean): number { + const gross = grossAmount(line); + if (pricesIncludeVat) return round2(gross - lineExcl(line, true)); + const pct = (line.vat_pct ?? DEFAULT_VAT_PCT) / 100; + return round2(lineExcl(line, false) * pct); +} + +/** The PO's three totals, from the lines and nothing else. */ +export function computeTotals(lines: DraftLine[], pricesIncludeVat: boolean): DraftTotals { + let subtotal = 0; + let vat = 0; + for (const l of lines) { + subtotal += lineExcl(l, pricesIncludeVat); + vat += lineVat(l, pricesIncludeVat); + } + subtotal = round2(subtotal); + vat = round2(vat); + return { subtotal, vat, total: round2(subtotal + vat) }; +} + +/** + * The PO's total against the quote's. + * + * A difference over fifty cents is a difference, and it is shown. A quote with + * no stated total is UNKNOWN — not a match and not a mismatch. + */ +export function reconcile(computed: DraftTotals, statedTotal: number | null): { difference: number | null; matches: boolean } { + if (statedTotal === null) return { difference: null, matches: false }; + const difference = round2(computed.total - statedTotal); + return { difference, matches: Math.abs(difference) <= 0.5 }; +} + +/** + * The next number in this system's series: MEGPPO-0001. + * + * A hyphen before the digits, so it can never collide with an ERP number + * (MEGPPO49676). When the office moves onto this system for good, the hyphen is + * the only thing to remove. + * + * The requisition number runs off the same counter, so a PO and its requisition + * carry the same digits and are obviously a pair. + */ +export function nextOrderNumbers(existingPoNumbers: string[]): { po_no: string; req_no: string } { + let highest = 0; + const re = new RegExp(`^${PO_NUMBER_PREFIX}${PO_NUMBER_SEPARATOR}(\\d{${PO_NUMBER_DIGITS}})$`); + for (const e of existingPoNumbers) { + const m = re.exec(String(e ?? "").trim().toUpperCase()); + if (m) highest = Math.max(highest, Number(m[1])); + } + const n = String(highest + 1).padStart(PO_NUMBER_DIGITS, "0"); + return { + po_no: `${PO_NUMBER_PREFIX}${PO_NUMBER_SEPARATOR}${n}`, + req_no: `${REQ_NUMBER_PREFIX}${PO_NUMBER_SEPARATOR}${n}`, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --import tsx --test lib/quote-draft.test.ts` +Expected: PASS — 13 tests, 0 failures + +- [ ] **Step 5: Commit** + +```bash +git add lib/quote-draft.ts lib/quote-draft.test.ts +git commit -m "feat(purchases): the draft maths — totals, reconciliation and the PO series" +``` + +--- + +### Task 2: The company block and the email + +**Files:** +- Create: `lib/company.ts` +- Create: `lib/purchase-order-email.ts` +- Test: `lib/purchase-order-email.test.ts` + +**Interfaces:** +- Consumes: `DraftLine`, `DraftTotals` from Task 1. +- Produces: + - `COMPANY` — `{ name, addressLines, vatRegistration, companyRegistration, headOffice: { tel, fax, cell }, deliveryAddress, logoPath, currency }` + - `TERMS_LINE` + - `paymentInLabel(terms: string): string` + - `type EmailPo` + - `poEmailSubject(po_no: string): string` + - `poEmailBody(po: EmailPo): string` + - `buildMailto(to: string, subject: string, body: string): string` + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/purchase-order-email.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildMailto, poEmailBody, poEmailSubject, type EmailPo } from "./purchase-order-email.ts"; + +const po: EmailPo = { + po_no: "MEGPPO-0001", + po_date: "2026-11-05", + required_by: "2026-11-20", + supplier: "PERCA SERVICES (PTY) LTD", + subject: "Skid steer coupler frame", + description: "Repair of SS02", + deliveryAddress: "Mega Park Business Park, 70 Motor street, Silverton, Pretoria, 0184", + currency: "ZAR", + subtotal: 11940, + vat: 1791, + total: 13731, + lines: [ + { description: "repair Skid Steer Quick Coupler Frame", qty: 1, unit: "", rate: 11940, amount_excl: 11940, total_incl: 13731 }, + ], +}; + +test("the subject names the PO and the company", () => { + assert.equal(poEmailSubject("MEGPPO-0001"), "Purchase Order MEGPPO-0001 - Mega Plant (Pty) Ltd"); +}); + +test("the body carries the order, the money and where it goes", () => { + const body = poEmailBody(po); + assert.ok(body.includes("MEGPPO-0001")); + assert.ok(body.includes("PERCA SERVICES (PTY) LTD")); + assert.ok(body.includes("repair Skid Steer Quick Coupler Frame")); + assert.ok(body.includes("13 731,00")); + assert.ok(body.includes("Mega Park Business Park")); +}); + +test("the mailto carries the address, the subject and the body", () => { + const link = buildMailto("orders@perca.co.za", poEmailSubject(po.po_no), poEmailBody(po)); + assert.ok(link.startsWith("mailto:orders@perca.co.za?")); + assert.ok(link.includes("subject=")); + assert.ok(link.includes("body=")); +}); + +test("the mailto escapes a body full of spaces and newlines", () => { + const link = buildMailto("a@b.co.za", "PO 1", "line one\nline two"); + assert.ok(!link.includes("\n")); + assert.ok(link.includes("%0A")); +}); + +test("a PO with no lines still reads as a PO", () => { + const body = poEmailBody({ ...po, lines: [] }); + assert.ok(body.includes("MEGPPO-0001")); + assert.ok(body.includes("13 731,00")); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --test lib/purchase-order-email.test.ts` +Expected: FAIL — "Cannot find module './purchase-order-email.ts'" + +- [ ] **Step 3: Write the implementation** + +```ts +// lib/company.ts +/** + * Mega Plant's own details, as the ERP prints them on every purchase order. + * + * ONE place. A PO raised here must carry the same block the ERP's POs carry, + * or the supplier is looking at a document from a different company. Every value + * here is transcribed from MEGPPO50532 — none of it is invented. + */ +export const COMPANY = { + name: "Mega Plant (Pty) Ltd", + addressLines: [ + "Mega Park Business Park", + "70 Motor Street", + "Silverton", + "Pretoria", + "0186", + ], + vatRegistration: "4830324812", + companyRegistration: "2026/055040/07", + headOffice: { + tel: "(012) 803 4456", + fax: "", + cell: "(012) 803 4456", + }, + /** The ERP prints 0184 here while the header says 0186. Transcribed as printed. */ + deliveryAddress: "Mega Park Business Park, 70 Motor street, Silverton, Pretoria, 0184", + logoPath: "public/brand/logo.png", + currency: "ZAR", +} as const; + +/** The words under the signatory line, as the ERP prints them. */ +export const TERMS_LINE = "Subject to our terms and conditions of purchase ( Available on request )"; + +/** + * "Payment In" on the PO, from the supplier's terms. + * + * The PO does not get its own terms field: it prints the supplier's, so the + * two screens cannot say different things about the same supplier. + */ +export function paymentInLabel(terms: string): string { + if (terms === "cash") return "Cash"; + if (terms === "30") return "30 Days"; + if (terms === "60") return "60 Days"; + return "Current"; +} +``` + +```ts +// lib/purchase-order-email.ts +import { ZAR2 } from "./utils"; +import { COMPANY } from "./company"; + +/** What the email needs to know about the PO it is sending. */ +export type EmailPo = { + po_no: string; + po_date: string; + required_by: string; + supplier: string; + subject: string; + description: string; + deliveryAddress: string; + currency: string; + subtotal: number; + vat: number; + total: number; + lines: { description: string; qty: number | null; unit: string; rate: number | null; amount_excl: number; total_incl: number }[]; +}; + +export function poEmailSubject(po_no: string): string { + return `Purchase Order ${po_no} - ${COMPANY.name}`; +} + +/** dd/mm/yyyy — the way a South African business writes a date. */ +const dmy = (iso: string) => { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || ""); + if (!m) return iso || ""; + return `${m[3]}/${m[2]}/${m[1]}`; +}; + +/** + * The body of the mail. + * + * It carries the order in full, so the person sending it can read it over + * before the attachment is even opened — and so a supplier who cannot open the + * PDF still knows what they were ordered. + */ +export function poEmailBody(po: EmailPo): string { + const lines = po.lines.map((l, i) => { + const qty = l.qty == null ? "" : `${l.qty}${l.unit ? ` ${l.unit}` : ""}`; + const rate = l.rate == null ? "" : ZAR2(l.rate); + return `${i + 1}. ${l.description}${qty ? ` (x${qty})` : ""} - ${rate}`; + }); + return [ + "Good day", + "", + `Please find our purchase order ${po.po_no} below.`, + "", + `PO number: ${po.po_no}`, + `Date: ${dmy(po.po_date)}`, + po.required_by ? `Required by: ${dmy(po.required_by)}` : "", + po.subject ? `Subject: ${po.subject}` : "", + po.description ? `Description: ${po.description}` : "", + "", + "Order:", + ...lines, + "", + `Subtotal: ${ZAR2(po.subtotal)}`, + `VAT: ${ZAR2(po.vat)}`, + `Total: ${ZAR2(po.total)} ${po.currency}`, + "", + "Delivery address:", + po.deliveryAddress, + "", + "The order document is attached. Please confirm receipt.", + "", + "Kind regards", + COMPANY.name, + `${COMPANY.headOffice.tel}`, + ].filter((l) => l !== "").join("\n"); +} + +/** + * A mailto: link. + * + * A browser cannot attach a file to a mailto: link — that is a platform limit, + * not a choice. The PDF is downloaded separately and dragged in. + */ +export function buildMailto(to: string, subject: string, body: string): string { + const enc = (s: string) => encodeURIComponent(s ?? ""); + return `mailto:${enc(to)}?subject=${enc(subject)}&body=${enc(body)}`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --import tsx --test lib/purchase-order-email.test.ts` +Expected: PASS — 5 tests, 0 failures + +- [ ] **Step 5: Commit** + +```bash +git add lib/company.ts lib/purchase-order-email.ts lib/purchase-order-email.test.ts +git commit -m "feat(purchases): the company block and the email the supplier reads" +``` + +--- + +### Task 3: The collection schemas + +**Files:** +- Create: `scripts/setup-app-settings-collection.mjs` +- Modify: `scripts/setup-purchase-collections.mjs` +- Modify: `scripts/setup-suppliers-collection.mjs` +- Modify: `lib/data.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: the collection `app_settings`, the new fields on `purchases`, and `email` on `suppliers`. The `Purchase` type in `lib/data.ts` gains `origin`, `subject`, `description`, `delivery_address`, `quote_no`, `quote_date`, `quote_total`, `quote_pdf`. + +- [ ] **Step 1: Write the new `app_settings` collection script** + +```js +// scripts/setup-app-settings-collection.mjs +// Create the `app_settings` collection (key/value). +// +// The AI key lives HERE, not in the environment, so it can be changed in the app +// without a redeploy — the way Mega Dash and WFDASH already do it. +// +// Two rules make it safe: +// 1. The collection's rules are empty, which in PocketBase means SUPERUSERS +// ONLY. The app's own users (Full, Clock-in, Mechanic, Office) cannot +// list, view, create, update or delete a row. +// 2. The `value` field is `hidden`, so it is not returned to anything that +// is not the server. The Settings page is handed the last four characters +// and nothing else. +// +// Idempotent: safe to re-run. +// +// Usage: node scripts/setup-app-settings-collection.mjs +import PocketBase from "pocketbase"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); + +const envPath = resolve(ROOT, ".env.local"); +if (!existsSync(envPath)) { + console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in."); + process.exit(1); +} +for (const line of readFileSync(envPath, "utf8").split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const i = t.indexOf("="); + if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim(); +} + +const PB_URL = process.env.NEXT_PUBLIC_PB_URL; +const EMAIL = process.env.PB_ADMIN; +const PASS = process.env.PB_PASS; +const missing = []; +if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL"); +if (!EMAIL) missing.push("PB_ADMIN"); +if (!PASS) missing.push("PB_PASS"); +if (missing.length) { + console.error(`✗ Missing required env: ${missing.join(", ")}`); + process.exit(1); +} + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); +console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`); +await pb.collection("_superusers").authWithPassword(EMAIL, PASS); +console.log(" ✓ authenticated"); + +// Mirror the rules the money tables use. Empty rules mean superusers only. +const reference = await pb.collections.getOne("monthly_income"); +const RULES = { + listRule: reference.listRule ?? "", + viewRule: reference.viewRule ?? "", + createRule: reference.createRule ?? null, + updateRule: reference.updateRule ?? null, + deleteRule: reference.deleteRule ?? null, +}; +console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`); + +const existing = await pb.collections.getFullList({ filter: `name = "app_settings"`, perPage: 1 }); +if (existing.length) { + const have = new Set(existing[0].fields.map((f) => f.name)); + const absent = [ + { name: "key", type: "text", required: true }, + { name: "value", type: "text", hidden: true }, + { name: "label", type: "text" }, + ].filter((f) => !have.has(f.name)); + if (absent.length) { + const coll = await pb.collections.getOne("app_settings"); + await pb.send(`/api/collections/${coll.name}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: [...coll.fields, ...absent] }), + }); + console.log(` ✓ added to app_settings: ${absent.map((f) => f.name).join(", ")}`); + } else { + console.log(` · app_settings already present (${existing[0].fields.length} fields)`); + } +} else { + const created = await pb.send("/api/collections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "app_settings", + type: "base", + fields: [ + { name: "key", type: "text", required: true }, + { name: "value", type: "text", hidden: true }, + { name: "label", type: "text" }, + { name: "created", type: "date", onCreate: true, presentable: false }, + { name: "updated", type: "date", onUpdate: true, presentable: false }, + ], + indexes: ["CREATE UNIQUE INDEX idx_app_settings_key ON app_settings (key)"], + ...RULES, + }), + }); + console.log(` ✓ created app_settings (${created.id})`); +} + +console.log("\n✓ app_settings ready. Add the AI key in the app's Settings page."); +process.exit(0); +``` + +- [ ] **Step 2: Add the new `purchases` fields** + +In `scripts/setup-purchase-collections.mjs`, inside the `ensureCollection("purchases", [...]` field list, add these eight fields **after** the existing `source_file` field and before the `created`/`updated` fields: + +```js + // ---- A PO raised in this app, from a supplier quote. + // `origin` separates the two kinds: "imported" is the ERP's own PDF, "made" is + // one we raised here. The page labels them differently. + { name: "origin", type: "text" }, + // The PO document's own words. They are stored because the document is + // regenerated FROM the record: a PO sent in November must still carry the + // same subject if it is opened in March. + { name: "subject", type: "text" }, + { name: "description", type: "text" }, + { name: "delivery_address", type: "text" }, + // The quote this PO was built from. The quote is the EVIDENCE — the PO's + // `total` is derived from the reviewed lines, and `quote_total` is what the + // quote said, kept so the two can be compared. + { name: "quote_no", type: "text" }, + { name: "quote_date", type: "text" }, + { name: "quote_total", type: "number" }, + { name: "quote_pdf", type: "file", maxSelect: 1, maxSize: 20971520 }, +``` + +- [ ] **Step 3: Add `email` to `suppliers`** + +In `scripts/setup-suppliers-collection.mjs`, inside the `fields` array, add after `{ name: "notes", type: "text" },`: + +```js + // Where this supplier's purchase order is sent. Empty until somebody knows it. + { name: "email", type: "text" }, +``` + +- [ ] **Step 4: Extend the `Purchase` type** + +In `lib/data.ts`, in the `Purchase` type, add these fields after `source_file: string;`: + +```ts + /** "imported" = the ERP's own PO PDF. "made" = a PO raised here from a quote. */ + origin: string; + /** The PO document's Subject, Description and where the order goes. */ + subject: string; + description: string; + delivery_address: string; + /** The quote this PO was built from. */ + quote_no: string; + quote_date: string; + /** What the QUOTE said. The PO's own total is derived from the reviewed lines. */ + quote_total: number; + quote_pdf: string; +``` + +- [ ] **Step 5: Run the setup scripts against the database** + +Run: +```bash +node scripts/setup-app-settings-collection.mjs && node scripts/setup-purchase-collections.mjs && node scripts/setup-suppliers-collection.mjs +``` +Expected: three `✓`/· lines, no errors. The scripts are idempotent — run them twice and the second run says "already present". + +- [ ] **Step 6: Verify the fields are really there** + +Run: +```bash +node --env-file=.env.local --input-type=module -e ' +const B=process.env.NEXT_PUBLIC_PB_URL; +const a=await (await fetch(B+"/api/collections/_superusers/auth-with-password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({identity:process.env.PB_ADMIN,password:process.env.PB_PASS})})).json(); +const H={Authorization:a.token}; +for (const c of ["app_settings","purchases","suppliers"]) { + const r=await (await fetch(B+"/api/collections/"+c,{headers:H})).json(); + console.log(c, "->", r.fields.map(f=>f.name).join(",")); +}' +``` +Expected: `app_settings -> key, value, label, created, updated`; `purchases ->` contains `origin, subject, description, delivery_address, quote_no, quote_date, quote_total, quote_pdf`; `suppliers ->` contains `email`. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/setup-app-settings-collection.mjs scripts/setup-purchase-collections.mjs scripts/setup-suppliers-collection.mjs lib/data.ts +git commit -m "feat(settings): the app_settings collection, and the fields a raised PO needs" +``` + +--- + +### Task 4: The AI settings + +**Files:** +- Create: `lib/app-settings.ts` +- Create: `app/api/settings/ai/route.ts` +- Create: `app/api/settings/ai/test/route.ts` +- Test: `lib/app-settings.test.ts` + +**Interfaces:** +- Consumes: the `app_settings` collection from Task 3. +- Produces: + - `type AiSettings = { provider: string; apiKey: string; model: string }` + - `type AiSettingsView = { provider: string; model: string; configured: boolean; masked: string }` + - `DEFAULT_AI_PROVIDER`, `DEFAULT_AI_MODEL` + - `maskKey(key: string): string` + - `readAiSettings(): Promise` + - `writeAiSettings(input: { provider?: string; model?: string; apiKey?: string; clearKey?: boolean }): Promise` + - `aiSettingsView(): Promise` + - `GET /api/settings/ai` → `{ ok: true, settings: AiSettingsView }` + - `PUT /api/settings/ai` → `{ ok: true, settings: AiSettingsView }` + - `POST /api/settings/ai/test` → `{ ok: true, message }` or `{ ok: false, error }` + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/app-settings.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { maskKey } from "./app-settings.ts"; + +test("a key is never shown in full", () => { + assert.equal(maskKey(""), ""); + assert.equal(maskKey(" "), ""); + assert.equal(maskKey("short"), "••••"); + assert.equal(maskKey("1234567890abcdef"), "•••• cdef"); + assert.ok(!maskKey("1234567890abcdef").includes("1234567890")); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --test lib/app-settings.test.ts` +Expected: FAIL — "Cannot find module './app-settings.ts'" + +- [ ] **Step 3: Write the implementation** + +```ts +// lib/app-settings.ts +import "server-only"; +import { serverAuth } from "@/lib/pb"; + +/** + * The app's own settings, in the database. + * + * The AI key lives here and NOT in the environment, so it can be changed in the + * app without a redeploy — the way Mega Dash and WFDASH already do it. + * + * The key never crosses to the browser: the Settings page is handed the last + * four characters and nothing else. + */ +export type AiSettings = { provider: string; apiKey: string; model: string }; +export type AiSettingsView = { provider: string; model: string; configured: boolean; masked: string }; + +const KEYS = { provider: "ai.provider", apiKey: "ai.api_key", model: "ai.model" }; + +export const DEFAULT_AI_PROVIDER = "minimax"; +export const DEFAULT_AI_MODEL = "MiniMax-M3.1-Flash"; + +/** + * The last four characters, and nothing else. + * + * A short key is masked completely: four characters of a six-character key is + * half the secret. + */ +export function maskKey(key: string): string { + const k = (key || "").trim(); + if (!k) return ""; + if (k.length <= 8) return "••••"; + return "•••• " + k.slice(-4); +} + +/** The settings, with the key in full. Server-side only. */ +export async function readAiSettings(): Promise { + const pb = await serverAuth(); + let rows: { key: string; value?: string }[] = []; + try { + rows = await pb.collection("app_settings").getFullList(); + } catch { + // The collection is not set up yet. The app still runs; the AI simply has no key. + return { provider: DEFAULT_AI_PROVIDER, apiKey: "", model: DEFAULT_AI_MODEL }; + } + const byKey = new Map(rows.map((r) => [r.key, r.value ?? ""])); + return { + provider: byKey.get(KEYS.provider) || DEFAULT_AI_PROVIDER, + apiKey: byKey.get(KEYS.apiKey) || "", + model: byKey.get(KEYS.model) || DEFAULT_AI_MODEL, + }; +} + +/** + * Save the settings. + * + * An EMPTY apiKey is IGNORED, never written: an empty submit must not wipe a + * working key. Clear the key deliberately instead, by sending the clear flag. + */ +export async function writeAiSettings(input: { provider?: string; model?: string; apiKey?: string; clearKey?: boolean }): Promise { + const pb = await serverAuth(); + const set = async (key: string, value: string) => { + const existing = await pb.collection("app_settings").getFirstListItem(`key = "${key}"`).catch(() => null); + if (existing) await pb.collection("app_settings").update(existing.id, { value }); + else await pb.collection("app_settings").create({ key, value }); + }; + if (input.provider) await set(KEYS.provider, input.provider.trim()); + if (input.model) await set(KEYS.model, input.model.trim()); + if (input.clearKey) await set(KEYS.apiKey, ""); + else if (input.apiKey && input.apiKey.trim()) await set(KEYS.apiKey, input.apiKey.trim()); +} + +/** The settings as the browser may see them. No key, ever. */ +export async function aiSettingsView(): Promise { + const s = await readAiSettings(); + return { provider: s.provider, model: s.model, configured: !!s.apiKey, masked: maskKey(s.apiKey) }; +} +``` + +- [ ] **Step 4: Write the API routes** + +```ts +// app/api/settings/ai/route.ts +import { refuseUnlessFull } from "@/lib/require-full"; +import { aiSettingsView, writeAiSettings } from "@/lib/app-settings"; + +export const dynamic = "force-dynamic"; + +// GET /api/settings/ai — what the Settings page shows. The key is masked; it +// is never in the response body. +export async function GET() { + try { + return Response.json({ ok: true, settings: await aiSettingsView() }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not read the settings" }, { status: 500 }); + } +} + +// PUT /api/settings/ai — save them. Full users only. +export async function PUT(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + let body: { provider?: unknown; model?: unknown; apiKey?: unknown; clearKey?: unknown }; + try { + body = await req.json(); + } catch { + return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + try { + await writeAiSettings({ + provider: typeof body.provider === "string" ? body.provider : undefined, + model: typeof body.model === "string" ? body.model : undefined, + apiKey: typeof body.apiKey === "string" ? body.apiKey : undefined, + clearKey: body.clearKey === true, + }); + return Response.json({ ok: true, settings: await aiSettingsView() }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not save the settings" }, { status: 500 }); + } +} +``` + +```ts +// app/api/settings/ai/test/route.ts +import { refuseUnlessFull } from "@/lib/require-full"; +import { readAiSettings } from "@/lib/app-settings"; + +export const dynamic = "force-dynamic"; + +// POST /api/settings/ai/test — one cheap call, so a person knows the key works +// before they rely on it. The key is never echoed back. +export async function POST() { + const refused = await refuseUnlessFull(); + if (refused) return refused; + const settings = await readAiSettings(); + if (!settings.apiKey) { + return Response.json({ ok: false, error: "No AI key is set yet." }, { status: 400 }); + } + try { + const res = await fetch("https://api.minimax.io/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${settings.apiKey}` }, + body: JSON.stringify({ + model: settings.model, + messages: [{ role: "user", content: "Reply with the single word: ready" }], + max_completion_tokens: 64, + reasoning_effort: "low", + }), + }); + if (!res.ok) { + const body = await res.text(); + return Response.json( + { ok: false, error: `The provider answered ${res.status}. ${body.slice(0, 200)}` }, + { status: 502 }, + ); + } + return Response.json({ ok: true, message: "Connected — the key works." }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not reach the provider" }, { status: 502 }); + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `node --import tsx --test lib/app-settings.test.ts` +Expected: PASS — 1 test, 0 failures + +- [ ] **Step 6: Commit** + +```bash +git add lib/app-settings.ts lib/app-settings.test.ts app/api/settings/ai/route.ts app/api/settings/ai/test/route.ts +git commit -m "feat(settings): the AI key, read from the database and never sent to the browser" +``` + +--- + +### Task 5: The AI reader + +**Files:** +- Create: `lib/quote-reader.ts` +- Create: `app/api/purchases/quote/read/route.ts` +- Test: `lib/quote-reader.test.ts` + +**Interfaces:** +- Consumes: `readAiSettings()` from Task 4; `cleanNumber`, `cleanDate`, `emptyLine`, `DEFAULT_VAT_PCT`, `DraftLine`, `QuoteDraft` from Task 1. +- Produces: + - `class QuoteReadError extends Error` + - `MAX_QUOTE_BYTES` + - `extractJson(raw: string): unknown | null` + - `toDraft(raw: unknown): QuoteDraft` + - `readQuote(file: { bytes: Uint8Array; mime: string; name: string }): Promise` + - `POST /api/purchases/quote/read` (multipart: `file`) → `{ ok: true, draft: QuoteDraft }` + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/quote-reader.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractJson, toDraft } from "./quote-reader.ts"; + +test("a reply that is only JSON is read", () => { + assert.deepEqual(extractJson('{"a":1}'), { a: 1 }); +}); + +test("a reply in a code fence is read", () => { + assert.deepEqual(extractJson('```json\n{"a":1}\n```'), { a: 1 }); +}); + +test("a reply with prose around the JSON is read", () => { + assert.deepEqual(extractJson('Here you go:\n{"a":1}\nHope that helps.'), { a: 1 }); +}); + +test("a reply with no JSON is refused, not guessed at", () => { + assert.equal(extractJson("I could not read this document."), null); + assert.equal(extractJson(""), null); +}); + +test("the AI's reply becomes a draft", () => { + const d = toDraft({ + supplier_name: "Specialized Fuel Injection Services (PTY) Ltd.", + quote_no: "4770110023", + quote_date: "27/10/2026", + currency: "ZAR", + prices_include_vat: false, + vat_pct: 15, + lines: [ + { description: "O HAUL INJECTORS", qty: 4, unit: null, rate: 425, discount_pct: 0, vat_pct: 15, line_total: 1700 }, + { description: "NOZZLES", qty: 4, unit: null, rate: 1201.2, discount_pct: 15, vat_pct: 15, line_total: 4084.08 }, + ], + stated_subtotal: 6122.08, + stated_vat: 918.31, + stated_total: 7040.39, + confidence: "high", + notes: ["Terms: COD."], + }); + assert.equal(d.supplierName, "Specialized Fuel Injection Services (PTY) Ltd."); + assert.equal(d.quoteNo, "4770110023"); + assert.equal(d.quoteDate, "2026-10-27"); + assert.equal(d.pricesIncludeVat, false); + assert.equal(d.statedTotal, 7040.39); + assert.equal(d.lines.length, 2); + assert.equal(d.lines[0].qty, 4); + assert.equal(d.lines[0].rate, 425); + assert.equal(d.lines[1].discount_pct, 15); + // The machine is never guessed: a quote carries no Mega cost code. + assert.equal(d.lines[0].assetId, ""); + assert.equal(d.lines[0].costCode, ""); +}); + +test("a line with no rate is kept, and stays unpriced", () => { + const d = toDraft({ lines: [{ description: "LABOUR", qty: 1, rate: null }] }); + assert.equal(d.lines.length, 1); + assert.equal(d.lines[0].rate, null); +}); + +test("a reply with no lines is a draft with no lines, not a crash", () => { + const d = toDraft({ supplier_name: "X" }); + assert.deepEqual(d.lines, []); + assert.equal(d.statedTotal, null); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --test lib/quote-reader.test.ts` +Expected: FAIL — "Cannot find module './quote-reader.ts'" + +- [ ] **Step 3: Write the implementation** + +```ts +// lib/quote-reader.ts +import "server-only"; +import { readAiSettings } from "./app-settings"; +import { cleanDate, cleanNumber, DEFAULT_VAT_PCT, emptyLine, type DraftLine, type QuoteDraft } from "./quote-draft"; + +export const MAX_QUOTE_BYTES = 20 * 1024 * 1024; + +/** A failure a person can act on. The reason is printed, not swallowed. */ +export class QuoteReadError extends Error { + constructor(message: string) { + super(message); + this.name = "QuoteReadError"; + } +} + +/** + * The prompt. + * + * It asks for STRICT JSON and nothing else, and it says what a null means: the + * document does not say. It must not guess a machine code — a quote carries no + * Mega cost code, and one that is invented would book spend against the wrong + * plant item. + */ +const READ_PROMPT = `You are reading a supplier document — a QUOTE or an INVOICE. +Extract only what is printed or legibly handwritten. Ignore stray handwriting that is not a field. + +Return STRICT JSON and nothing else. No prose, no markdown fence. +{ + "doc_type": "quote | invoice | unknown", + "supplier_name": "", + "supplier_contact": "", + "supplier_email": "", + "supplier_address": "", + "quote_no": "", + "quote_date": "", + "currency": "ZAR", + "prices_include_vat": true, + "vat_pct": 15, + "lines": [ + { "description": "", "qty": 0, "unit": "", "rate": 0, "discount_pct": 0, "vat_pct": 15, "line_total": 0 } + ], + "stated_subtotal": 0, + "stated_vat": 0, + "stated_total": 0, + "confidence": "high | medium | low", + "notes": [] +} + +Rules: +- Use null for a number the document does not state. Never guess a number. +- prices_include_vat is TRUE only if the unit prices already include VAT. +- The discount is the percentage the document shows in a discount column. +- Put anything you could not read, or that looks odd, in notes. +- Never invent a Mega machine code or a cost code. There are none on a quote.`; + +/** + * The JSON out of a reply that may be wrapped in a fence or in prose. + * + * A reply with no JSON is `null` — the caller refuses it. A half-read quote is + * worse than no quote. + */ +export function extractJson(raw: string): unknown | null { + const t = (raw || "").trim(); + if (!t) return null; + const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(t); + const body = fenced ? fenced[1] : t; + const start = body.indexOf("{"); + const end = body.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + try { + return JSON.parse(body.slice(start, end + 1)); + } catch { + return null; + } +} + +const str = (v: unknown) => String(v ?? "").trim(); + +/** The AI's reply, as a draft a person can edit. */ +export function toDraft(raw: unknown): QuoteDraft { + const r = (raw ?? {}) as Record; + const rawLines = Array.isArray(r.lines) ? (r.lines as Record[]) : []; + const lines: DraftLine[] = rawLines + .map((l) => { + const line = emptyLine(); + line.description = str(l.description); + line.qty = cleanNumber(l.qty); + line.unit = str(l.unit); + line.rate = cleanNumber(l.rate); + line.discount_pct = cleanNumber(l.discount_pct); + line.vat_pct = cleanNumber(l.vat_pct) ?? DEFAULT_VAT_PCT; + line.materialCode = str(l.material_code); + return line; + }) + .filter((l) => l.description || l.rate != null); + const confidence = str(r.confidence).toLowerCase(); + return { + supplierName: str(r.supplier_name), + supplierCode: str(r.supplier_code), + supplierEmail: str(r.supplier_email), + quoteNo: str(r.quote_no), + quoteDate: cleanDate(r.quote_date), + currency: str(r.currency) || "ZAR", + pricesIncludeVat: r.prices_include_vat === true, + lines, + statedSubtotal: cleanNumber(r.stated_subtotal), + statedVat: cleanNumber(r.stated_vat), + statedTotal: cleanNumber(r.stated_total), + confidence: confidence === "high" || confidence === "medium" || confidence === "low" ? confidence : "medium", + notes: Array.isArray(r.notes) ? (r.notes as unknown[]).map((n) => str(n)).filter(Boolean) : [], + }; +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +/** A PDF goes on the Anthropic-compatible route; an image on the OpenAI one. */ +async function callMiniMax(settings: { apiKey: string; model: string }, file: { bytes: Uint8Array; mime: string; name: string }): Promise { + const isPdf = file.mime === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + const base64 = toBase64(file.bytes); + if (isPdf) { + const res = await fetch("https://api.minimax.io/anthropic/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": settings.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: settings.model, + max_tokens: 16000, + messages: [ + { + role: "user", + content: [ + { type: "document", source: { type: "base64", media_type: "application/pdf", data: base64 } }, + { type: "text", text: READ_PROMPT }, + ], + }, + ], + }), + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new QuoteReadError(`The document could not be read (${res.status}). ${String(body?.base_resp?.status_msg || "").slice(0, 160)}`); + } + return (body?.content || []).map((c: { text?: string }) => c.text || "").join(""); + } + + const res = await fetch("https://api.minimax.io/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${settings.apiKey}` }, + body: JSON.stringify({ + model: settings.model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: READ_PROMPT }, + { type: "image_url", image_url: { url: `data:${file.mime || "image/png"};base64,${base64}` } }, + ], + }, + ], + max_completion_tokens: 16000, + // Without this the model spends the whole token budget thinking and + // returns NOTHING. It is a budget fact, not a preference. + reasoning_effort: "low", + }), + }); + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new QuoteReadError(`The document could not be read (${res.status}). ${String(body?.base_resp?.status_msg || "").slice(0, 160)}`); + } + return String(body?.choices?.[0]?.message?.content || ""); +} + +/** + * Read a quote into a draft. + * + * Throws `QuoteReadError` with a reason a person can act on. It never writes + * anything — the draft is a proposal until a person approves it. + */ +export async function readQuote(file: { bytes: Uint8Array; mime: string; name: string }): Promise { + const settings = await readAiSettings(); + if (!settings.apiKey) throw new QuoteReadError("No AI key is set. Add one in Settings."); + if (settings.provider !== "minimax") throw new QuoteReadError(`The provider "${settings.provider}" cannot read documents yet.`); + const raw = await callMiniMax(settings, file); + const json = extractJson(raw); + if (!json) throw new QuoteReadError("The document could not be read as a quote."); + const draft = toDraft(json); + if (!draft.lines.length) throw new QuoteReadError("No line items were found in that document."); + return draft; +} +``` + +- [ ] **Step 4: Write the read route** + +```ts +// app/api/purchases/quote/read/route.ts +import { refuseUnlessFull } from "@/lib/require-full"; +import { MAX_QUOTE_BYTES, QuoteReadError, readQuote } from "@/lib/quote-reader"; + +export const dynamic = "force-dynamic"; + +// POST /api/purchases/quote/read +// multipart/form-data: file (a quote — PDF, JPG or PNG) +// +// Reads the document with AI and returns a DRAFT. Nothing is written to the +// database here: the draft is a proposal until a person approves it. +export async function POST(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + + let form: FormData; + try { + form = await req.formData(); + } catch { + return Response.json({ ok: false, error: "Expected a multipart upload" }, { status: 400 }); + } + const file = form.get("file"); + if (!(file instanceof File)) { + return Response.json({ ok: false, error: "No file was uploaded" }, { status: 400 }); + } + const name = file.name.toLowerCase(); + const isPdf = file.type === "application/pdf" || name.endsWith(".pdf"); + const isImage = file.type.startsWith("image/") || /\.(jpe?g|png|webp)$/.test(name); + if (!isPdf && !isImage) { + return Response.json({ ok: false, error: "A quote must be a PDF, JPG or PNG" }, { status: 400 }); + } + if (file.size > MAX_QUOTE_BYTES) { + return Response.json({ ok: false, error: "That file is larger than the 20 MB limit" }, { status: 400 }); + } + + try { + const draft = await readQuote({ + bytes: new Uint8Array(await file.arrayBuffer()), + mime: isPdf ? "application/pdf" : file.type || "image/png", + name: file.name, + }); + return Response.json({ ok: true, draft }); + } catch (e) { + const reason = e instanceof QuoteReadError ? e.message : (e as Error)?.message || "Could not read that document"; + return Response.json({ ok: false, error: reason }, { status: 400 }); + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `node --import tsx --test lib/quote-reader.test.ts` +Expected: PASS — 6 tests, 0 failures + +- [ ] **Step 6: Prove the reader against a REAL document** + +This is the step that matters. With a key in `app_settings`, read a document the register already holds and check the figures against the database. + +Run: +```bash +node --env-file=.env.local --input-type=module -e ' +const B=process.env.NEXT_PUBLIC_PB_URL; +const a=await (await fetch(B+"/api/collections/_superusers/auth-with-password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({identity:process.env.PB_ADMIN,password:process.env.PB_PASS})})).json(); +const H={Authorization:a.token}; +const r=await (await fetch(B+"/api/collections/purchase_invoices/records?perPage=50",{headers:H})).json(); +const inv=(r.items||[]).find(i=>i.invoice_no==="51013"); +const res=await fetch(B+"/api/files/purchase_invoices/"+inv.id+"/"+inv.source_pdf,{headers:H}); +const fs=await import("node:fs"); +fs.writeFileSync("/tmp/quote-probe.pdf", Buffer.from(await res.arrayBuffer())); +console.log("probe written:", inv.invoice_no, "expected amount:", inv.amount_incl); +' +curl -s -X POST http://localhost:6007/api/purchases/quote/read -F "file=@/tmp/quote-probe.pdf" +``` +Expected: the JSON draft shows supplier "Specialized Fuel Injection", 3 lines, and `stated_total` of 7040.39 — the amount the register holds for invoice 51013. + +If the dev server is not running, start it first: `npm run dev` (port 6007). + +- [ ] **Step 7: Commit** + +```bash +git add lib/quote-reader.ts lib/quote-reader.test.ts app/api/purchases/quote/read/route.ts +git commit -m "feat(purchases): read a supplier quote with AI into a reviewable draft" +``` + +--- + +### Task 6: The PO document + +**Files:** +- Create: `lib/purchase-order-pdf.ts` +- Test: `lib/purchase-order-pdf.test.ts` + +**Interfaces:** +- Consumes: `COMPANY`, `TERMS_LINE`, `paymentInLabel` from Task 2. +- Produces: + - `type PoPdfLine` + - `type PoPdfData` + - `buildPurchaseOrderPdf(data: PoPdfData): Promise` + +- [ ] **Step 1: Write the failing test** + +```ts +// lib/purchase-order-pdf.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildPurchaseOrderPdf } from "./purchase-order-pdf.ts"; + +const data = { + po_no: "MEGPPO-0001", + req_no: "MEGPREQ-0001", + po_date: "2026-11-05", + required_by: "2026-11-20", + supplier: "PERCA SERVICES (PTY) LTD", + supplier_code: "09PER001", + supplier_address: "", + supplier_contact: "", + supplier_email: "", + subject: "Skid steer coupler frame", + description: "Repair of SS02", + orderedBy: "Sandrea van Niekerk", + orderedByEmail: "sandrea@megainfra.co.za", + orderedByPhone: "082 290 5333", + deliveryAddress: "Mega Park Business Park, 70 Motor street, Silverton, Pretoria, 0184", + currency: "ZAR", + paymentIn: "30 Days", + subtotal: 11940, + vat: 1791, + total: 13731, + lines: [ + { + line_no: 1, material_code: "950499", cost_code: "SS-02", + description: "repair Skid Steer Quick Coupler Frame", + qty: 1, unit: "", rate: 11940, disc_pct: 0, vat_pct: 15, + amount_excl: 11940, vat: 1791, total_incl: 13731, + }, + ], +}; + +/** Read the generated PDF back, so the test checks the DOCUMENT and not the code. */ +async function textOf(bytes: Uint8Array): Promise { + const { createRequire } = await import("node:module"); + const req = createRequire(import.meta.url); + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + try { + pdfjs.GlobalWorkerOptions.workerSrc = req.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs"); + } catch { /* the fallback path resolves it next to pdf.mjs */ } + const doc = await pdfjs.getDocument({ data: bytes.slice() }).promise; + let out = ""; + for (let p = 1; p <= doc.numPages; p++) { + const page = await doc.getPage(p); + const tc = await page.getTextContent(); + out += tc.items.map((i: any) => i.str).join(" ") + "\n"; + } + return out; +} + +test("the document is a PDF", async () => { + const bytes = await buildPurchaseOrderPdf(data); + assert.equal(String.fromCharCode(...bytes.slice(0, 5)), "%PDF-"); +}); + +test("the document carries the PO number, the supplier and the total", async () => { + const text = await textOf(await buildPurchaseOrderPdf(data)); + assert.ok(text.includes("MEGPPO-0001")); + assert.ok(text.includes("PERCA SERVICES (PTY) LTD")); + assert.ok(text.includes("13 731.00") || text.includes("13731.00")); + assert.ok(text.includes("Skid Steer")); +}); + +test("the document carries the company block and the signatory line", async () => { + const text = await textOf(await buildPurchaseOrderPdf(data)); + assert.ok(text.includes("Mega Plant (Pty) Ltd")); + assert.ok(text.includes("4830324812")); + assert.ok(text.includes("Authorised Signatory")); + assert.ok(text.includes("Subject to our terms and conditions of purchase")); +}); + +test("a PO with no lines still prints", async () => { + const bytes = await buildPurchaseOrderPdf({ ...data, lines: [] }); + const text = await textOf(bytes); + assert.ok(text.includes("MEGPPO-0001")); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --test lib/purchase-order-pdf.test.ts` +Expected: FAIL — "Cannot find module './purchase-order-pdf.ts'" + +- [ ] **Step 3: Write the implementation** + +```ts +// lib/purchase-order-pdf.ts +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { COMPANY, TERMS_LINE } from "./company"; + +/** + * The purchase order document. + * + * Generated ON THE SERVER from a saved PO, so the document a supplier is sent + * and the record in the register are the same thing — there is no second copy + * to keep in step. + * + * The layout copies the ERP's own PO (Ref: STD_po_DN.rpt), because that is what + * the supplier already knows: the same blocks, the same line table, the same + * words under the signatory line. + */ +export type PoPdfLine = { + line_no: number; + material_code: string; + cost_code: string; + description: string; + qty: number | null; + unit: string; + rate: number | null; + disc_pct: number | null; + vat_pct: number | null; + amount_excl: number; + vat: number; + total_incl: number; +}; + +export type PoPdfData = { + po_no: string; + req_no: string; + po_date: string; + required_by: string; + supplier: string; + supplier_code: string; + supplier_address: string; + supplier_contact: string; + supplier_email: string; + subject: string; + description: string; + orderedBy: string; + orderedByEmail: string; + orderedByPhone: string; + deliveryAddress: string; + currency: string; + paymentIn: string; + subtotal: number; + vat: number; + total: number; + lines: PoPdfLine[]; +}; + +const A4: [number, number] = [595.28, 841.89]; + +/** + * The logo, from disk. + * + * The runner image copies `public/` in, so this resolves in production too. + * If it ever cannot, the wordmark alone still identifies the document — the + * document is never lost to a missing file. + */ +function logoBytes(): Uint8Array | null { + for (const p of [ + join(process.cwd(), "public", "brand", "logo.png"), + join(process.cwd(), "..", "public", "brand", "logo.png"), + ]) { + try { + return new Uint8Array(readFileSync(p)); + } catch { /* try the next one */ } + } + return null; +} + +const num = (n: number) => n.toFixed(2); +/** dd/mm/yyyy — the way a South African business writes a date. */ +const dmy = (iso: string) => { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || ""); + return m ? `${m[3]}/${m[2]}/${m[1]}` : iso || ""; +}; + +export async function buildPurchaseOrderPdf(data: PoPdfData): Promise { + const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib"); + const doc = await PDFDocument.create(); + const page = doc.addPage(A4); + const reg = await doc.embedFont(StandardFonts.Helvetica); + const bold = await doc.embedFont(StandardFonts.HelveticaBold); + + const INK = rgb(0.08, 0.08, 0.1); + const GOLD = rgb(0.93, 0.68, 0.29); + const LINE = rgb(0.25, 0.28, 0.4); + const RULE = rgb(0.72, 0.74, 0.8); + + const X = 34; + const W = A4[0] - 68; + const H = A4[1]; + const half = (W - 10) / 2; + const box = (x: number, y: number, w: number, h: number, width = 0.9) => + page.drawRectangle({ x, y, width: w, height: h, borderColor: LINE, borderWidth: width }); + const text = (s: string, x: number, y: number, size = 9, font = reg, color = INK) => + page.drawText(s, { x, y, size, font, color }); + const vline = (x: number, y1: number, y2: number, width = 0.7) => + page.drawLine({ start: { x, y: y1 }, end: { x, y: y2 }, thickness: width, color: LINE }); + const hline = (y: number, x1: number, x2: number, color = LINE) => + page.drawLine({ start: { x: x1, y }, end: { x: x2, y }, thickness: 0.5, color }); + + // ---- Header: company block left, logo right + let y = H - 34; + text(COMPANY.name, X, y - 12, 13, bold); + COMPANY.addressLines.forEach((line, i) => text(line, X, y - 24 - i * 11, 8.5, bold)); + const vatY = y - 24 - COMPANY.addressLines.length * 11; + text(`Vat Registration : ${COMPANY.vatRegistration}`, X, vatY, 8.5, bold); + text(`Company Registration : ${COMPANY.companyRegistration}`, X, vatY - 11, 8.5, bold); + + // The ERP prints a wide lockup: the emblem and the wordmark beside it. + const logoRight = X + W; + const bytes = logoBytes(); + if (bytes) { + try { + const img = await doc.embedPng(bytes); + const h = 46; + const w = (img.width / img.height) * h; + page.drawImage(img, { x: logoRight - w - 130, y: y - 52, width: w, height: h }); + const megaW = bold.widthOfTextAtSize("MEGA", 22); + const plantW = bold.widthOfTextAtSize("PLANT", 22); + text("MEGA", logoRight - 120 - megaW - plantW, y - 38, 22, bold, GOLD); + text("PLANT", logoRight - 120 - plantW, y - 38, 22, bold, INK); + } catch { /* the wordmark alone still identifies the form */ } + } + + // ---- Title + y -= 74; + text(`Purchase Order(s) #${data.po_no}`, X, y, 15, bold); + y -= 18; + + // ---- Two boxes: Subject | Description + const boxTop = (label: string, value: string, x: number) => { + box(x, y - 48, half, 48); + text(label, x + 8, y - 16, 9, bold); + if (value) text(value, x + 80, y - 16, 9, reg); + }; + boxTop("Subject :", data.subject, X); + boxTop("Description :", data.description, X + half + 10); + y -= 56; + + // ---- Two boxes: To | PO details + box(X, y - 150, half, 150); + let ty = y - 16; + text(`To : ${data.supplier}`, X + 8, ty, 9, bold); + ty -= 14; + const field = (label: string, value: string) => { + if (!value) return; + text(label, X + 8, ty, 8.5, bold); + text(value, X + 92, ty, 8.5, reg); + ty -= 12; + }; + field("Code :", data.supplier_code); + field("Address :", data.supplier_address); + field("Contact :", data.supplier_contact); + field("Email :", data.supplier_email); + + box(X + half + 10, y - 150, half, 150); + let dy = y - 16; + const detail = (label: string, value: string) => { + text(label, X + half + 18, dy, 8.5, bold); + text(value, X + half + 100, dy, 8.5, reg); + dy -= 12; + }; + detail("PO Creation Date :", dmy(data.po_date)); + detail("Required By :", dmy(data.required_by)); + detail("Req No :", data.req_no); + detail("Ordered By :", data.orderedBy); + detail("Telephone :", data.orderedByPhone); + detail("Email :", data.orderedByEmail); + detail("Currency :", `(${data.currency}) South African Rand`); + detail("Payment In :", data.paymentIn); + y -= 158; + + // ---- Two boxes: blank | Contact + Delivery Address + box(X, y - 60, half, 60); + box(X + half + 10, y - 60, half, 60); + text("Contact :", X + half + 18, y - 16, 8.5, bold); + text(`Head office, Tel : ${COMPANY.headOffice.tel}, Cell : ${COMPANY.headOffice.cell}`, X + half + 100, y - 16, 8.5, reg); + text("Delivery Address :", X + half + 18, y - 34, 8.5, bold); + text(data.deliveryAddress, X + half + 118, y - 34, 8.5, reg); + y -= 68; + + // ---- The line table, with the totals block bottom-right + const tableTop = y; + const tableH = 250; + const cols = [X, X + 26, X + 78, X + 145, X + 290, X + 330, X + 400, X + 455, X + 495, X + W - 60]; + const heads = ["No", "Material Code", "Cost Code", "Description", "Quantity", "Unit", "Rate", "DISC%", "VAT%", "Amount"]; + box(X, tableTop - tableH, W, tableH); + heads.forEach((h, i) => { + text(h, cols[i] + 4, tableTop - 14, 8, bold); + if (i) vline(cols[i], tableTop, tableTop - tableH); + }); + hline(tableTop - 20, X, X + W); + + const bodyBottom = tableTop - tableH + 92; // the totals block starts here + const rowGap = 16; + data.lines.slice(0, 22).forEach((l, i) => { + const ry = tableTop - 20 - rowGap * (i + 1) + 4; + if (ry < bodyBottom + 8) return; + text(String(l.line_no), cols[0] + 4, ry, 8); + text(l.material_code, cols[1] + 4, ry, 8); + text(l.cost_code, cols[2] + 4, ry, 8); + // The description wraps inside its column rather than running over the money. + const desc = l.description || ""; + const maxW = cols[4] - cols[3] - 12; + if (reg.widthOfTextAtSize(desc, 8) <= maxW) { + text(desc, cols[3] + 4, ry, 8); + } else { + const words = desc.split(/\s+/); + let first = ""; + const rest: string[] = []; + for (const w of words) { + if (!first && reg.widthOfTextAtSize(`${first} ${w}`.trim(), 8) <= maxW) first = `${first} ${w}`.trim(); + else rest.push(w); + } + text(first, cols[3] + 4, ry, 8); + if (rest.length) text(rest.join(" "), cols[3] + 4, ry - 10, 8); + } + text(l.qty == null ? "" : String(l.qty), cols[4] + 4, ry, 8); + text(l.unit || "", cols[5] + 4, ry, 8); + text(l.rate == null ? "" : num(l.rate), cols[6] + 4, ry, 8); + text(l.disc_pct == null ? "" : num(l.disc_pct), cols[7] + 4, ry, 8); + text(l.vat_pct == null ? "" : num(l.vat_pct), cols[8] + 4, ry, 8); + text(num(l.amount_excl), cols[9] + 4, ry, 8); + }); + + // The totals block, bottom-right, as the ERP prints it. + const totalsX = cols[6]; + vline(totalsX, tableTop - tableH + 92, tableTop - tableH); + let ty2 = tableTop - tableH + 78; + const totalRow = (label: string, value: string) => { + text(label, totalsX + 10, ty2, 9, bold); + text(value, X + W - 66, ty2, 9, bold); + ty2 -= 14; + }; + totalRow("Sub Total Amount :", num(data.subtotal)); + totalRow("VAT Amount :", num(data.vat)); + totalRow("Total Amount :", num(data.total)); + + y = tableTop - tableH - 10; + + // ---- Authorised signatory + terms + box(X, y - 56, W, 56); + text("Authorised Signatory :", X + 8, y - 18, 9, bold); + hline(y - 21, X + 130, X + 300); + text(TERMS_LINE, X + 8, y - 44, 9, bold); + y -= 66; + + // ---- Footer + const now = new Date(); + text(`Printed ${now.toISOString().slice(0, 10)} ${now.toISOString().slice(11, 16)}`, X, y - 4, 8, reg, RULE); + text("Page 1 of 1", X + W - 60, y - 4, 8, reg, RULE); + + return doc.save(); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --import tsx --test lib/purchase-order-pdf.test.ts` +Expected: PASS — 4 tests, 0 failures + +- [ ] **Step 5: Look at the document** + +A PDF that passes a text test can still be a mess on the page. Render it and LOOK at it. + +Run: +```bash +node --import tsx --input-type=module -e ' +import { buildPurchaseOrderPdf } from "./lib/purchase-order-pdf.ts"; +import { writeFileSync } from "node:fs"; +const bytes = await buildPurchaseOrderPdf({ + po_no: "MEGPPO-0001", req_no: "MEGPREQ-0001", po_date: "2026-11-05", required_by: "2026-11-20", + supplier: "PERCA SERVICES (PTY) LTD", supplier_code: "09PER001", supplier_address: "1 Bay Road, Durban", + supplier_contact: "Johan", supplier_email: "johan@perca.co.za", + subject: "Skid steer coupler frame", description: "Repair of SS02", + orderedBy: "Sandrea van Niekerk", orderedByEmail: "sandrea@megainfra.co.za", orderedByPhone: "082 290 5333", + deliveryAddress: "Mega Park Business Park, 70 Motor street, Silverton, Pretoria, 0184", + currency: "ZAR", paymentIn: "30 Days", subtotal: 11940, vat: 1791, total: 13731, + lines: [{ line_no: 1, material_code: "950499", cost_code: "SS-02", description: "repair Skid Steer Quick Coupler Frame", + qty: 1, unit: "", rate: 11940, disc_pct: 0, vat_pct: 15, amount_excl: 11940, vat: 1791, total_incl: 13731 }], +}); +writeFileSync("/tmp/po-sample.pdf", bytes); +console.log("written", bytes.length); +' +pdftoppm -png -r 100 -f 1 -l 1 /tmp/po-sample.pdf /tmp/po-sample +``` +Expected: `/tmp/po-sample-1.png` exists. **Read that image** and compare it with the ERP's PO: the company block top-left, the logo top-right, the title, the four boxes, the line table with the totals bottom-right, the signatory line, the footer. Fix what does not look right and re-run. + +- [ ] **Step 6: Commit** + +```bash +git add lib/purchase-order-pdf.ts lib/purchase-order-pdf.test.ts +git commit -m "feat(purchases): the PO document, in the ERP's own house layout" +``` + +--- + +### Task 7: Raising the PO + +**Files:** +- Create: `app/api/purchases/route.ts` +- Create: `app/api/purchases/[id]/document/route.ts` + +**Interfaces:** +- Consumes: `computeTotals`, `lineExcl`, `lineVat`, `nextOrderNumbers`, `DraftLine` from Task 1; `resolveSupplier` from `lib/supplier-server.ts`; `normaliseCostCode` from `lib/cost-code.ts`; `buildPurchaseOrderPdf`, `PoPdfData` from Task 6; `COMPANY`, `paymentInLabel` from Task 2. +- Produces: + - `POST /api/purchases` → `{ ok: true, purchase: { id, po_no, supplier, total }, lines: number, unallocated: string[] }` + - `GET /api/purchases/:id/document` → the PDF; `?download=1` sends it as an attachment + +- [ ] **Step 1: Write the create route** + +```ts +// app/api/purchases/route.ts +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; +import { resolveSupplier } from "@/lib/supplier-server"; +import { normaliseCostCode } from "@/lib/cost-code"; +import { computeTotals, lineExcl, lineVat, nextOrderNumbers, type DraftLine } from "@/lib/quote-draft"; + +export const dynamic = "force-dynamic"; + +// POST /api/purchases — raise a PO from a draft a person has approved. +// +// The client sends the LINES and the header. It does NOT send the totals: they +// are computed here from the lines, so a wrong total can never be written. +// +// The body is multipart so the quote PDF can ride along: "payload" carries the +// JSON, "quote_pdf" carries the file. +type Body = { + po_date?: unknown; + required_by?: unknown; + supplier_name?: unknown; + supplier_code?: unknown; + supplier_id?: unknown; + supplier_email?: unknown; + subject?: unknown; + description?: unknown; + delivery_address?: unknown; + currency?: unknown; + prices_include_vat?: unknown; + quote_no?: unknown; + quote_date?: unknown; + quote_total?: unknown; + quote_pdf?: unknown; + lines?: unknown; +}; + +export async function POST(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + + let body: Body; + const contentType = req.headers.get("content-type") || ""; + try { + if (contentType.includes("multipart/form-data")) { + const form = await req.formData(); + body = JSON.parse(String(form.get("payload") || "{}")); + body.quote_pdf = form.get("quote_pdf"); + } else { + body = await req.json(); + } + } catch { + return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 }); + } + + const str = (v: unknown) => String(v ?? "").trim(); + const lines = (Array.isArray(body.lines) ? body.lines : []) as Record[]; + if (!lines.length) return Response.json({ ok: false, error: "A purchase order needs at least one line" }, { status: 400 }); + + // The machine each line is booked to. A cost code NEVER guesses a machine. + const draftLines: DraftLine[] = lines.map((l) => ({ + description: str(l.description), + qty: l.qty == null ? null : Number(l.qty), + unit: str(l.unit), + rate: l.rate == null ? null : Number(l.rate), + discount_pct: l.discount_pct == null ? null : Number(l.discount_pct), + vat_pct: l.vat_pct == null ? null : Number(l.vat_pct), + assetId: str(l.asset), + costCode: str(l.cost_code), + materialCode: str(l.material_code), + })); + for (const l of draftLines) { + if (!l.description && l.rate == null) { + return Response.json({ ok: false, error: "Every line needs a description or a rate" }, { status: 400 }); + } + } + + try { + const pb = await serverAuth(); + + // The PO number is generated HERE, from what is on file. The client cannot + // choose it, and a number already on file is refused. + const existing = await pb.collection("purchases").getFullList({ fields: "po_no" }); + const { po_no, req_no } = nextOrderNumbers(existing.map((p) => p.po_no)); + if (existing.find((p) => (p.po_no || "").toUpperCase() === po_no.toUpperCase())) { + return Response.json({ ok: false, error: `${po_no} is already on file` }, { status: 409 }); + } + + const pricesIncludeVat = body.prices_include_vat === true; + const totals = computeTotals(draftLines, pricesIncludeVat); + + // The supplier: the one the person picked, or found/created by name and code. + const supplierName = str(body.supplier_name); + if (!supplierName) return Response.json({ ok: false, error: "Choose a supplier" }, { status: 400 }); + let supplierId = str(body.supplier_id); + if (!supplierId) { + supplierId = await resolveSupplier(pb, { + name: supplierName, + code: str(body.supplier_code), + source: "Purchase order", + }); + } + + const po_date = str(body.po_date) || new Date().toISOString().slice(0, 10); + const purchase = await pb.collection("purchases").create({ + po_no, + req_no, + origin: "made", + supplier: supplierName, + supplier_code: str(body.supplier_code), + ...(supplierId ? { supplier_ref: supplierId } : {}), + po_date, + required_by: str(body.required_by), + subject: str(body.subject), + description: str(body.description), + delivery_address: str(body.delivery_address), + currency: str(body.currency) || "ZAR", + subtotal: totals.subtotal, + vat: totals.vat, + total: totals.total, + quote_no: str(body.quote_no), + quote_date: str(body.quote_date), + quote_total: body.quote_total == null ? 0 : Number(body.quote_total), + }); + + // The lines. The quote is attached AFTER the lines, never before: a storage + // failure must not leave a PO header with no lines. + const unallocated: string[] = []; + const assets = await pb.collection("assets").getFullList({ fields: "id,code" }); + for (let i = 0; i < draftLines.length; i++) { + const l = draftLines[i]; + let assetId = l.assetId || ""; + const normalised = normaliseCostCode(l.costCode); + if (!assetId && normalised) { + // A code was typed but no machine was chosen — find it, and never guess. + const hit = assets.find((a) => normaliseCostCode(a.code || "") === normalised); + if (hit) assetId = hit.id; + } + if (!assetId) unallocated.push(`#${i + 1} ${l.costCode || "(no machine)"}`); + + const amountExcl = lineExcl(l, pricesIncludeVat); + const vat = lineVat(l, pricesIncludeVat); + await pb.collection("purchase_lines").create({ + purchase: purchase.id, + line_no: i + 1, + material_code: l.materialCode, + cost_code: l.costCode, + asset: assetId, + description: l.description, + qty: l.qty, + rate: l.rate, + disc_pct: l.discount_pct, + vat_pct: l.vat_pct, + amount_excl: amountExcl, + vat, + total_incl: Math.round((amountExcl + vat) * 100) / 100, + }); + } + + // The quote, if it came with the PO. It is the evidence the PO was built from. + const quote = body.quote_pdf; + let warning: string | undefined; + if (quote instanceof File && quote.size > 0) { + try { + await pb.collection("purchases").update(purchase.id, { + quote_pdf: new File([await quote.arrayBuffer()], quote.name || "quote.pdf", { + type: quote.type || "application/pdf", + }), + }); + } catch (e) { + console.error(`[purchases] ${po_no}: the quote was not attached — ${(e as Error).message}`); + warning = "The purchase order was raised, but the quote could not be attached."; + } + } + + revalidatePath("/running/purchases"); + revalidatePath("/running"); + revalidatePath("/expenses/suppliers"); + + return Response.json({ + ok: true, + warning, + purchase: { id: purchase.id, po_no, supplier: supplierName, total: totals.total }, + lines: draftLines.length, + unallocated, + }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not raise the purchase order" }, { status: 500 }); + } +} +``` + +- [ ] **Step 2: Write the document route** + +```ts +// app/api/purchases/[id]/document/route.ts +import { serverAuth } from "@/lib/pb"; +import { getSession } from "@/lib/auth"; +import { buildPurchaseOrderPdf, type PoPdfData } from "@/lib/purchase-order-pdf"; +import { COMPANY, paymentInLabel } from "@/lib/company"; +import { normaliseCostCode } from "@/lib/cost-code"; + +export const dynamic = "force-dynamic"; + +// GET /api/purchases/:id/document — the PO document, generated from the saved +// record. `?download=1` sends it as an attachment, which is what the email +// hand-off needs; without it the browser opens it. +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const pb = await serverAuth(); + const purchase = await pb.collection("purchases").getOne(id); + const lines = await pb.collection("purchase_lines").getFullList({ filter: `purchase = "${id}"`, sort: "line_no" }); + const assets = await pb.collection("assets").getFullList({ fields: "id,code" }); + const byId = new Map(assets.map((a) => [a.id, a])); + const supplier = await pb.collection("suppliers").getOne(purchase.supplier_ref || "").catch(() => null); + // "Ordered By" is the SIGNED-IN user, not a hard-coded person. + const session = await getSession(); + + const data: PoPdfData = { + po_no: purchase.po_no, + req_no: purchase.req_no || "", + po_date: purchase.po_date || "", + required_by: purchase.required_by || "", + supplier: purchase.supplier || "", + supplier_code: purchase.supplier_code || "", + // `suppliers` has no address or contact field — `notes` holds whatever the + // office keeps about them. Never print a field the collection does not have. + supplier_address: supplier?.notes || "", + supplier_contact: supplier?.name || "", + supplier_email: supplier?.email || "", + subject: purchase.subject || "", + description: purchase.description || "", + orderedBy: String(session?.user?.name || "Mega Plant"), + orderedByEmail: String(session?.user?.email || ""), + orderedByPhone: COMPANY.headOffice.tel, + deliveryAddress: purchase.delivery_address || COMPANY.deliveryAddress, + currency: purchase.currency || COMPANY.currency, + paymentIn: paymentInLabel(supplier?.terms || ""), + subtotal: purchase.subtotal || 0, + vat: purchase.vat || 0, + total: purchase.total || 0, + lines: lines.map((l) => ({ + line_no: l.line_no, + material_code: l.material_code || "", + cost_code: l.cost_code || normaliseCostCode(byId.get(l.asset || "")?.code || ""), + description: l.description || "", + qty: l.qty ?? null, + unit: "", + rate: l.rate ?? null, + disc_pct: l.disc_pct ?? null, + vat_pct: l.vat_pct ?? null, + amount_excl: l.amount_excl || 0, + vat: l.vat || 0, + total_incl: l.total_incl ?? 0, + })), + }; + + const bytes = await buildPurchaseOrderPdf(data); + const download = new URL(req.url).searchParams.get("download") === "1"; + return new Response(bytes as BodyInit, { + headers: { + "Content-Type": "application/pdf", + "Content-Length": String(bytes.length), + ...(download ? { "Content-Disposition": `attachment; filename="${purchase.po_no}.pdf"` } : {}), + }, + }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not build the document" }, { status: 404 }); + } +} +``` + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. Fix any — this project has a clean baseline. + +- [ ] **Step 4: Commit** + +```bash +git add app/api/purchases/route.ts "app/api/purchases/[id]/document/route.ts" +git commit -m "feat(purchases): raise a PO from an approved draft, and serve its document" +``` + +--- + +### Task 8: The composer — upload and read + +**Files:** +- Create: `components/PurchaseOrderComposer.tsx` + +**Interfaces:** +- Consumes: `POST /api/purchases/quote/read` (Task 5); `QuoteDraft`, `DraftLine`, `emptyLine`, `computeTotals`, `reconcile`, `lineExcl` (Task 1); `ZAR2` from `lib/utils`. +- Produces: `export function PurchaseOrderComposer(props: ComposerProps)` — a client component with: + - `export type ComposerMachine = { id: string; code: string; name: string; category: string }` + - `export type ComposerSupplier = { id: string; name: string; code: string; terms: string; email: string }` + - `export type ComposerUser = { name: string; email: string }` + - `export type ComposerProps = { machines: ComposerMachine[]; suppliers: ComposerSupplier[]; user: ComposerUser; onClose: () => void }` + +**Interfaces for later tasks:** +- Task 9 extends this file with the review form and the save. +- Task 10 mounts it on `/running/purchases`. + +- [ ] **Step 1: Write the shell** + +```tsx +// components/PurchaseOrderComposer.tsx +"use client"; + +import { useState } from "react"; +import { Loader2, TriangleAlert, Upload, X, FileText } from "lucide-react"; +import type { QuoteDraft } from "@/lib/quote-draft"; + +export type ComposerMachine = { id: string; code: string; name: string; category: string }; +export type ComposerSupplier = { id: string; name: string; code: string; terms: string; email: string }; +export type ComposerUser = { name: string; email: string }; +export type ComposerProps = { + machines: ComposerMachine[]; + suppliers: ComposerSupplier[]; + user: ComposerUser; + onClose: () => void; +}; + +/** + * The purchase order composer. + * + * Step one is the QUOTE. A quote is not a purchase order: it is a proposal from + * a supplier, and it is read by AI because supplier quotes are scans and have + * no text layer to read. + * + * The AI proposes; a person disposes. Nothing is written until the person says + * so, and what the AI read is shown against the quote for exactly that reason. + */ +export function PurchaseOrderComposer({ machines, suppliers, user, onClose }: ComposerProps) { + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [quoteFile, setQuoteFile] = useState(null); + + async function read(file: File) { + setBusy(true); + setError(null); + setDraft(null); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/purchases/quote/read", { method: "POST", body: fd }); + const body = await res.json(); + if (!body.ok) { + setError(body.error || "That document could not be read."); + return; + } + setDraft(body.draft); + } catch { + setError("Could not read that document — check your connection."); + } finally { + setBusy(false); + } + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-label="Create purchase order" + className="w-full max-w-5xl bg-white dark:bg-surface rounded-2xl p-5 shadow-xl my-auto" + > +
+
+

Create purchase order

+

+ Upload the supplier's quote. It is read for you, then you check every line. +

+
+ +
+ + + + {busy && ( +
+ Reading the quote… +
+ )} + {error && ( +
+ +
+
Could not read that document
+
{error}
+
+
+ )} + + {draft && ( +
+
+ {quoteFile?.name} +
+
+ {draft.supplierName || "No supplier name read"} · {draft.lines.length} line{draft.lines.length === 1 ? "" : "s"} + {draft.quoteNo ? ` · quote ${draft.quoteNo}` : ""} +
+ {draft.confidence !== "high" && ( +
+ The reader was only {draft.confidence} sure — check every line. +
+ )} + {draft.notes.length > 0 && ( +
    + {draft.notes.map((n, i) =>
  • {n}
  • )} +
+ )} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: no errors. (The build is slow — it compiles the whole app.) + +- [ ] **Step 3: Commit** + +```bash +git add components/PurchaseOrderComposer.tsx +git commit -m "feat(purchases): the composer's first step — upload a quote and read it" +``` + +--- + +### Task 9: The composer — the review form + +**Files:** +- Modify: `components/PurchaseOrderComposer.tsx` +- Modify: `components/MachineCombobox.tsx` + +**Interfaces:** +- Consumes: Task 8's component; `MachineCombobox`; `POST /api/purchases` (Task 7). +- Produces: the composer's second step — the editable line table, the machine picker, the supplier picker, the VAT toggle, the reconciliation banner, and the save. + +- [ ] **Step 1: Give `MachineCombobox` an `onChange`** + +Read `components/MachineCombobox.tsx`. It takes a `value` but has no way to tell its parent what was picked. Add one: + +```tsx + // Add to the props type: + onChange?: (assetId: string, code: string) => void; +``` + +and call it wherever the component sets the value — inside the option-pick handler, right after it sets the asset id and the code: + +```tsx + onChange?.(hit.id, hit.code); +``` + +Leave every existing behaviour alone. The invoice register uses this component too, and it must not change. + +- [ ] **Step 2: Add the review state** + +Add to `PurchaseOrderComposer`: + +```tsx + const [lines, setLines] = useState([]); + const [pricesIncludeVat, setPricesIncludeVat] = useState(false); + const [supplierId, setSupplierId] = useState(""); + const [supplierName, setSupplierName] = useState(""); + const [supplierCode, setSupplierCode] = useState(""); + const [supplierEmail, setSupplierEmail] = useState(""); + const [subject, setSubject] = useState(""); + const [description, setDescription] = useState(""); + const [requiredBy, setRequiredBy] = useState(""); + const [saving, setSaving] = useState(false); +``` + +Add the imports: `import { computeTotals, emptyLine, lineExcl, reconcile, type DraftLine, type QuoteDraft } from "@/lib/quote-draft";` and `import { ZAR2 } from "@/lib/utils";` and `import { Plus } from "lucide-react";`. + +Add the helpers, above the component: + +```tsx +const ACCENT = "#30638E"; + +const inputCls = + "min-h-[44px] w-full rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm text-[#003D5B] dark:text-white"; + +const numOrNull = (v: string) => { + const t = v.trim().replace(/,/g, ""); + if (!t) return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; +}; +``` + +When a draft arrives, seed the state from it: + +```tsx + function applyDraft(d: QuoteDraft) { + setDraft(d); + setLines(d.lines); + setPricesIncludeVat(d.pricesIncludeVat); + setSupplierName(d.supplierName); + setSupplierCode(d.supplierCode); + setSupplierEmail(d.supplierEmail); + const match = suppliers.find( + (s) => (d.supplierCode && s.code === d.supplierCode) || + s.name.toLowerCase() === d.supplierName.toLowerCase(), + ); + setSupplierId(match?.id || ""); + } + + function updateLine(i: number, patch: Partial) { + setLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + } +``` + +Call `applyDraft(body.draft)` instead of `setDraft(body.draft)` in `read()`. + +- [ ] **Step 3: Add the review markup** + +Render, below the existing quote summary, the review block. The line table is a WIDE table: it scrolls inside its own container, the **Description** column is sticky, and the sticky cell carries an **OPAQUE** background — the same trap the invoice register hit. + +```tsx + {draft && ( +
+
+ + + + + + +
+ +
+
+ + + + + + + + + + + + + + + {lines.map((l, i) => ( + + + + + + + + + + + ))} + +
#DescriptionQtyRateDisc %VAT %MachineAmount
{i + 1} + updateLine(i, { description: e.target.value })} + className="w-full bg-transparent text-[#003D5B] dark:text-gray-300 outline-none" + /> + + updateLine(i, { qty: numOrNull(e.target.value) })} + className="w-16 bg-transparent outline-none tabular-nums" /> + + updateLine(i, { rate: numOrNull(e.target.value) })} + className="w-24 bg-transparent outline-none tabular-nums" /> + + updateLine(i, { discount_pct: numOrNull(e.target.value) })} + className="w-16 bg-transparent outline-none tabular-nums" /> + + updateLine(i, { vat_pct: numOrNull(e.target.value) })} + className="w-16 bg-transparent outline-none tabular-nums" /> + + updateLine(i, { assetId, costCode: code })} + /> + + {ZAR2(lineExcl(l, pricesIncludeVat))} +
+
+
+
+ + +
+ )} +``` + +- [ ] **Step 4: Add the totals, the reconciliation and the VAT toggle** + +Above the `return`: + +```tsx + const totals = computeTotals(lines, pricesIncludeVat); + const recon = reconcile(totals, draft?.statedTotal ?? null); +``` + +And in the markup, after the lines block: + +```tsx + {draft && lines.length > 0 && ( +
+
+ +
+
Subtotal {ZAR2(totals.subtotal)} · VAT {ZAR2(totals.vat)}
+
Total {ZAR2(totals.total)}
+
+
+ {recon && !recon.matches && ( +
+ + The quote says {ZAR2(draft.statedTotal ?? 0)} — your lines come to {ZAR2(totals.total)}, a difference of {ZAR2(Math.abs(recon.difference ?? 0))}. Check the lines. +
+ )} + {recon && recon.matches && ( +
Your lines match the quote's total exactly.
+ )} +
+ )} +``` + +- [ ] **Step 5: Add the save** + +```tsx + async function save() { + if (!draft) return; + if (!supplierName.trim()) { setError("Choose a supplier, or type its name."); return; } + if (!lines.length) { setError("A purchase order needs at least one line."); return; } + setSaving(true); + setError(null); + try { + const fd = new FormData(); + fd.append("payload", JSON.stringify({ + supplier_name: supplierName, + supplier_code: supplierCode, + supplier_id: supplierId, + supplier_email: supplierEmail, + subject, + description, + required_by: requiredBy, + currency: draft.currency, + prices_include_vat: pricesIncludeVat, + quote_no: draft.quoteNo, + quote_date: draft.quoteDate, + quote_total: draft.statedTotal, + lines: lines.map((l) => ({ + description: l.description, + qty: l.qty, + unit: l.unit, + rate: l.rate, + discount_pct: l.discount_pct, + vat_pct: l.vat_pct, + cost_code: l.costCode, + asset: l.assetId, + material_code: l.materialCode, + })), + })); + if (quoteFile) fd.append("quote_pdf", quoteFile); + const res = await fetch("/api/purchases", { method: "POST", body: fd }); + const body = await res.json(); + if (!body.ok) { setError(body.error || "Could not raise the purchase order."); return; } + if (body.unallocated?.length) { + setError(`${body.purchase.po_no} was raised, but these lines have no machine: ${body.unallocated.join(", ")}`); + } else { + setError(null); + } + setDone(body.purchase.po_no); + } catch { + setError("Could not raise the purchase order — check your connection."); + } finally { + setSaving(false); + } + } +``` + +Add `const [done, setDone] = useState(null);` to the state, and the button: + +```tsx + {draft && ( +
+ + +
+ )} +``` + +- [ ] **Step 6: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add components/PurchaseOrderComposer.tsx components/MachineCombobox.tsx +git commit -m "feat(purchases): the review form — every line editable, every line checkable" +``` + +--- + +### Task 10: The Purchases page — the button and the PO actions + +**Files:** +- Modify: `components/PurchaseIntake.tsx` +- Modify: `app/(dashboard)/running/purchases/page.tsx` + +**Interfaces:** +- Consumes: `PurchaseOrderComposer` and its types (Tasks 8–9); `buildMailto`, `poEmailBody`, `poEmailSubject` (Task 2). +- Produces: the "Create purchase order" button, and per-PO "PO document" and "Email" actions. + +- [ ] **Step 1: Read the page and the component first** + +Read `app/(dashboard)/running/purchases/page.tsx` and `components/PurchaseIntake.tsx` end to end. Note the existing `PurchaseView` type, the props `PurchaseIntake` takes, where the "Upload purchase orders" button lives, and where the expanded PO panel renders its header fields. The steps below assume that shape; **if the real names differ, use the real ones** — do not rename things to match this plan. + +- [ ] **Step 2: Pass the data the composer needs** + +In the page, add `pb.collection("suppliers").getFullList({ sort: "name" })` to the `Promise.all`, and build the options: + +```ts + const supplierOptions = suppliers.map((s: any) => ({ + id: s.id, + name: s.name || "", + code: s.code || "", + terms: s.terms || "", + email: s.email || "", + })); +``` + +Add `import { requireUser } from "@/lib/auth";` and `const { user } = await requireUser();` to the page, then pass to `PurchaseIntake`: + +```tsx + ({ id: a.id, code: a.code || "", name: a.name || "", category: a.category || "" }))} + suppliers={supplierOptions} + user={{ name: user?.name || "", email: user?.email || "" }} + /> +``` + +- [ ] **Step 3: Add the button and the composer** + +In `components/PurchaseIntake.tsx`, add the state and the button next to "Upload purchase orders": + +```tsx + const [composer, setComposer] = useState(false); +``` + +```tsx + +``` + +And render the composer: + +```tsx + {composer && ( + setComposer(false)} + /> + )} +``` + +Extend the props: + +```tsx +export function PurchaseIntake({ + purchases: allPurchases, + machines, + suppliers, + user, +}: { + purchases: PurchaseView[]; + machines: ComposerMachine[]; + suppliers: ComposerSupplier[]; + user: ComposerUser; +}) { +``` + +and add the imports: + +```tsx +import { PurchaseOrderComposer, type ComposerMachine, type ComposerSupplier, type ComposerUser } from "@/components/PurchaseOrderComposer"; +import { buildMailto, poEmailBody, poEmailSubject } from "@/lib/purchase-order-email"; +``` + +- [ ] **Step 4: Add the per-PO document and email actions** + +Inside the expanded PO panel, after the header fields: + +```tsx +
+ + PO document + + {p.supplierEmail && ( + + )} +
+``` + +The email handler — download the PDF FIRST, then open the draft a beat later, so the download is not cancelled by the navigation: + +```tsx + function emailPo(p: PurchaseView) { + // 1. The document downloads. + const a = document.createElement("a"); + a.href = `/api/purchases/${p.id}/document?download=1`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + // 2. The draft opens a beat later, so the download has started. + const body = poEmailBody({ + po_no: p.po_no, + po_date: p.po_date, + required_by: p.required_by, + supplier: p.supplier, + subject: p.subject, + description: p.description, + deliveryAddress: p.deliveryAddress, + currency: p.currency, + subtotal: p.subtotal, + vat: p.vat, + total: p.total, + lines: p.lines.map((l) => ({ + description: l.description, + qty: l.qty, + unit: "", + rate: l.rate, + amount_excl: l.amount_excl, + total_incl: l.total_incl ?? l.amount_excl, + })), + }); + setTimeout(() => { + window.location.href = buildMailto(p.supplierEmail, poEmailSubject(p.po_no), body); + }, 400); + } +``` + +This needs `supplierEmail`, `subject`, `description` and `deliveryAddress` on `PurchaseView`. Add them to the type and to the mapping in the page: + +```ts + supplierEmail: suppliersById.get(p.supplier_ref || "")?.email || "", + subject: p.subject || "", + description: p.description || "", + deliveryAddress: p.delivery_address || "", +``` + +- [ ] **Step 5: Label the two kinds of PO** + +Next to the PO number, show where the PO came from: + +```tsx + {p.origin === "made" && ( + + Raised here + + )} +``` + +- [ ] **Step 6: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add components/PurchaseIntake.tsx "app/(dashboard)/running/purchases/page.tsx" +git commit -m "feat(purchases): raise a PO from the page, and email it to the supplier" +``` + +--- + +### Task 11: Settings — the AI section + +**Files:** +- Create: `components/AiSettings.tsx` +- Modify: `app/(dashboard)/settings/page.tsx` + +**Interfaces:** +- Consumes: `GET /api/settings/ai`, `PUT /api/settings/ai`, `POST /api/settings/ai/test` (Task 4). +- Produces: `export function AiSettings()` — a client component. + +- [ ] **Step 1: Write the component** + +```tsx +// components/AiSettings.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Check, TriangleAlert, Plug } from "lucide-react"; + +/** + * The AI key, edited in the app. + * + * The key lives in the DATABASE, not in the environment, so it can be changed + * without a redeploy. The browser is handed the last four characters and + * nothing else — the key itself never crosses the wire to a page. + */ +export function AiSettings() { + const [provider, setProvider] = useState("minimax"); + const [model, setModel] = useState("MiniMax-M3.1-Flash"); + const [masked, setMasked] = useState(""); + const [configured, setConfigured] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/settings/ai") + .then((r) => r.json()) + .then((b) => { + if (!b.ok) { setError(b.error || "Could not read the settings"); return; } + setProvider(b.settings.provider); + setModel(b.settings.model); + setMasked(b.settings.masked); + setConfigured(b.settings.configured); + }) + .catch(() => setError("Could not read the settings")); + }, []); + + async function save() { + setSaving(true); + setError(null); + setMessage(null); + try { + const res = await fetch("/api/settings/ai", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + // An EMPTY key is never sent, so an empty submit cannot wipe a working key. + body: JSON.stringify({ provider, model, ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}) }), + }); + const body = await res.json(); + if (!body.ok) { setError(body.error || "Could not save"); return; } + setMasked(body.settings.masked); + setConfigured(body.settings.configured); + setApiKey(""); + setMessage("Saved."); + } catch { + setError("Could not save — check your connection"); + } finally { + setSaving(false); + } + } + + async function test() { + setTesting(true); + setError(null); + setMessage(null); + try { + const res = await fetch("/api/settings/ai/test", { method: "POST" }); + const body = await res.json(); + if (!body.ok) { setError(body.error || "The connection failed"); return; } + setMessage(body.message || "Connected."); + } catch { + setError("Could not reach the provider"); + } finally { + setTesting(false); + } + } + + const inputCls = + "min-h-[44px] w-full rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm text-[#003D5B] dark:text-white"; + + return ( +
+
+ +
+

AI & automation

+

+ Reads supplier quotes so a purchase order can be raised from them. The key is stored in + the database, so you can change it here without a redeploy. +

+
+
+ +
+ + + +
+ + {error && ( +
+ {error} +
+ )} + {message && ( +
+ {message} +
+ )} + +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Mount it on the Settings page** + +In `app/(dashboard)/settings/page.tsx`, add `import { AiSettings } from "@/components/AiSettings";` and render it after the users table: + +```tsx +
+ +
+``` + +- [ ] **Step 3: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/AiSettings.tsx "app/(dashboard)/settings/page.tsx" +git commit -m "feat(settings): the AI key, edited in the app" +``` + +--- + +### Task 12: The supplier's email + +**Files:** +- Modify: `components/Suppliers2View.tsx` +- Modify: `app/api/suppliers/[id]/route.ts` +- Modify: `lib/supplier-spend.ts` +- Modify: `lib/supplier-spend-data.ts` + +**Interfaces:** +- Consumes: the `email` field on `suppliers` (Task 3). +- Produces: the supplier's email, editable on the Suppliers page. + +- [ ] **Step 1: Check the existing supplier route** + +Read `app/api/suppliers/[id]/route.ts`. If it already accepts a body of `{ terms }` only, extend it to accept `email` as well, in the same write: + +```ts + // Alongside the existing terms handling: + if (typeof body.email === "string") { + await pb.collection("suppliers").update(id, { email: body.email.trim() }); + } +``` + +Keep the existing rules exactly: a write needs a Full user, and a PO must never change an existing supplier's terms. + +- [ ] **Step 2: Add the email to the supplier row** + +In `components/Suppliers2View.tsx`, add the address next to the supplier's name, and make it editable in the same place the terms are edited — the existing terms modal. Extend that modal with an email field, and save both together: + +```tsx + const [email, setEmail] = useState(row.email || ""); +``` + +```tsx + const res = await fetch(`/api/suppliers/${row.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terms, email }), + }); +``` + +Show it on the row, and show **"no email"** in amber when it is empty — the Email button on a PO cannot work without it: + +```tsx + {r.email ? ( + {r.email} + ) : ( + no email + )} +``` + +The `SupplierSpendRow` type needs `email: string` — add it in `lib/supplier-spend.ts` and pass it through the loader in `lib/supplier-spend-data.ts`. + +- [ ] **Step 3: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/Suppliers2View.tsx lib/supplier-spend.ts lib/supplier-spend-data.ts "app/api/suppliers/[id]/route.ts" +git commit -m "feat(suppliers): where each supplier's purchase order is sent" +``` + +--- + +### Task 13: The rules, and the full verification + +**Files:** +- Modify: `AGENTS.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: the house rules for this feature, in the house voice. + +- [ ] **Step 1: Write the AGENTS.md section** + +Add a section after **"Purchases — a cost code is a machine, not a guess"**: + +```markdown +## A purchase order RAISED here, from a supplier quote + +The ERP import is one direction. This is the other: a supplier sends a quote, +Mega decides to buy, and Mega raises its own PO and sends it out. + +- **The quote is read by AI**, because supplier quotes are SCANS — the invoice on + file is a CamScanner scan with handwriting on it. There is no text layer, so + the coordinate parser does not apply. A PDF goes to MiniMax on the + Anthropic-compatible route, an image on the OpenAI-compatible one. +- **The reasoning effort of "low" is not a preference.** Without it the model + spends the whole token budget thinking and returns NOTHING. +- **The AI proposes; a person disposes.** The draft is shown against the quote + and every line is editable. Nothing is written until the person saves. +- **The PO's totals are DERIVED from the reviewed lines, never copied from the + quote.** The quote's stated total is kept as quote_total and COMPARED to the + computed total; a difference over R0,50 is shown in amber. A PO raised here + therefore always balances by construction. +- **Prices may be VAT-inclusive.** The reader reports which it read, the person + can flip the toggle, and the reconciliation catches it if it was wrong. +- **The PO number is MEGPPO-0001** — a hyphen before the digits, so it can + never collide with the ERP's MEGPPO49676. When the office moves onto this + system for good, the hyphen is the only thing to remove. +- **A cost code NEVER guesses a machine**, here either. A line with no machine + is saved with an EMPTY asset and reported. +- **The document is generated on the SERVER from the saved record**, so the + order the supplier is sent and the register's row are the same thing. + purchases.source_pdf stays the ERP's document; purchases.quote_pdf is the + evidence the PO was built from. +- **A browser cannot attach a file to a mailto link.** The Email button + downloads the document and opens a pre-filled draft, and the person drags it + in. Sending from the system is the next step. +- **The AI key lives in app_settings**, not in the environment, so it can be + changed in Settings without a redeploy. The collection's rules are empty + (superusers only) and the value field is hidden — the key never reaches + the browser. +``` + +- [ ] **Step 2: Run the whole test suite** + +Run: `npm run test` +Expected: every test passes, including the ones that were already there. + +- [ ] **Step 3: Typecheck** + +Run: `npm run typecheck` +Expected: no errors. This project has a clean baseline — any error is yours. + +- [ ] **Step 4: Build** + +Run: `npm run build` +Expected: the build succeeds, with the new routes in the output. + +- [ ] **Step 5: The mobile audit** + +Start the dev server: `npm run dev` (port 6007). + +At **375×812** and **768×1024**, on `/running/purchases` and `/settings`: + +- `document.documentElement.scrollWidth > document.documentElement.clientWidth` is **false** +- no text under 12px +- no tap target under 44×44px +- the line table scrolls inside its own container, the Description column sticky, the + sticky cell OPAQUE +- the composer stacks below `lg` rather than sitting side by side + +Screenshot every route. **If a route fails any of these, it is not done.** + +- [ ] **Step 6: The end-to-end walk** + +1. Add a MiniMax key in Settings. Test connection says "Connected". +2. Upload a real quote. The lines come back. +3. Change a line. The total follows. The reconciliation reacts. +4. Save. The PO appears in the list, marked "Raised here". +5. Open the PO document. It looks like the ERP's PO. +6. Email it. The PDF downloads and the draft opens. +7. Attach an invoice to it, the way you do today. The status moves to "Partly invoiced". + +- [ ] **Step 7: Commit** + +```bash +git add AGENTS.md +git commit -m "docs(running): the rules for a purchase order raised from a quote" +``` + +- [ ] **Step 8: Push** + +```bash +git push forgejo main +``` + +--- + +## Self-review + +**Spec coverage** + +| Spec section | Task | +|---|---| +| The flow (upload → read → review → save → document → email) | 5, 7, 8, 9, 10 | +| Reading the quote with AI (two routes, the JSON, the budgeting fact) | 5 | +| The review step (derived totals, the reconciliation, the VAT toggle, the machine) | 1, 9 | +| The supplier (find-or-create, terms unset) | 7 | +| The PO document (the ERP's layout) | 6 | +| The email hand-off (download + draft) | 2, 10 | +| The PO number (MEGPPO-0001) | 1, 7 | +| The AI key in Settings | 3, 4, 11 | +| Data changes (app_settings, purchases, suppliers) | 3 | +| Errors, refusals and honesty | 4, 5, 7, 9 | +| Mobile | 13 | +| Tests | 1, 2, 4, 5, 6 | +| Out of scope | — (nothing in the plan reaches it) | + +**Type consistency** — DraftLine, QuoteDraft, DraftTotals, AiSettings, AiSettingsView, PoPdfData, PoPdfLine, EmailPo, ComposerMachine, ComposerSupplier, ComposerUser and ComposerProps are each defined once, in the task that first needs them, and used with the same names afterwards. + +**Known follow-ups the implementer must handle in place** — Task 7's document route reads supplier.address, supplier.contact and purchase.ordered_by, which do not exist yet. Use the real fields (suppliers.notes and suppliers.name) and the session user instead, as Task 7's note says. Task 10's step 1 requires reading the real page and component before changing them. + +**Placeholders** — none. Every code step carries the code. + + + +