From 36ae6d9c97c0409ac9418f076d1197ee40cd7388 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 22:28:32 +0200 Subject: [PATCH] docs(plan): the implementation plan for purchase orders raised from a quote 13 tasks, 73 steps. The pure core first (totals, reconciliation, the PO series), then the collections, the AI key, the reader, the document, the routes, the composer, and the page wiring. Each task carries its own failing test, its implementation, its passing run and its commit. The reader is proved against a real document before the composer is built on it. --- .../2026-11-05-purchase-order-from-quote.md | 3253 +++++++++++++++++ 1 file changed, 3253 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-05-purchase-order-from-quote.md 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. + + + +