13 KiB
Purchase Order Upload + n8n Ingestion — Design
Date: 2026-08-19 Status: Approved (pending implementation) Owner: DELs
Goal
Let users upload Purchase Order documents (PDF or scanned image) from the /dashboard/purchasing page, have an n8n workflow extract the PO data, and land a verified-ready record in the existing purchase_orders collection — without giving n8n PocketBase admin credentials and without blocking the user on OCR.
Non-goals (v1)
- Email-forwarded PO ingestion (same
/ingestendpoint will be reused later). - Bulk multi-file upload in one dialog.
- Re-running OCR on an existing PO.
- Editing PO fields post-extraction via a form (the existing
VerifyButtonflow is enough for v1). - Realtime UI updates —
force-dynamicpage re-fetch is sufficient.
Architecture
Browser → Next.js API → n8n → Next.js callback → PocketBase
All PocketBase writes stay in pbAdmin() (matches existing pattern in src/app/api/delivery-notes/upload/route.ts and src/app/api/timesheets/import/route.ts). n8n authenticates to the callback via a shared secret header — it never holds PocketBase credentials.
┌──────────┐ multipart ┌──────────────────┐ forward ┌────────────┐
│ Browser │ ──────────► │ Next.js │ ────────► │ n8n │
│ (Upload │ │ /api/purchase- │ file │ webhook │
│ Button) │ ◄────────── │ orders/upload │ ◄──────── │ (OCR + │
│ │ 202 proc. │ │ 202 ack │ parse) │
└──────────┘ └──────────────────┘ └─────┬──────┘
│
│ callback
▼
┌──────────────────┐
│ /api/purchase- │
│ orders/ingest │
│ (verify secret) │
└───────┬──────────┘
│ pbAdmin()
▼
┌────────────────┐
│ PocketBase │
│ purchase_orders│
│ + items │
└────────────────┘
Step-by-step
- User clicks "Upload Purchase Order" in the Purchasing topbar.
- Browser POSTs the file (PDF/JPG/PNG/WEBP, ≤20 MB) to
/api/purchase-orders/uploadasmultipart/form-datawith fieldsfile,filename,contentType,size. - Next.js
/api/purchase-orders/upload:- Validates file type + size (early-return 400 if bad).
- Forwards the file via
fetchto${N8N_PO_INGEST_URL}(server-only env var). - Returns
202 { status: "processing", filename }immediately — does not wait for OCR.
- n8n receives the file via webhook, runs OCR (Tesseract for images,
pdf-parseor equivalent for PDFs), parses PO fields + line items, then POSTs the structured payload to/api/purchase-orders/ingestwith headerx-ingest-secret: ${N8N_INGEST_SECRET}. - Next.js
/api/purchase-orders/ingest:- Verifies the secret header.
- Validates payload with the shared schema validator.
- Looks up
sitebyproject_code(exact → starts-with → no-match). - Creates the
purchase_ordersrecord (with file + denormalized fields). - Creates each
purchase_order_itemsrow in a sequentialforloop inside the route handler (PocketBase SDK has no true batch insert — eachcreate()is a separate HTTP request, but they all share the samepbAdmin()auth and run within the single ingest request). - Returns
200 { ok: true, id, po_number, warnings? }to n8n.
- Dashboard is
force-dynamic— next page load shows the new PO.
Data model
PocketBase collections
purchase_orders — see src/lib/pb-types.ts:76-90 and scripts/pb-bootstrap-schema.py:104-125. No schema changes needed.
purchase_order_items — see src/lib/pb-types.ts:92-106.
Payload contract (n8n → /ingest)
export type PurchaseOrderIngestPayload = {
po_number: string; // required, unique-indexed
project_code: string; // required (used to match site)
supplier: string; // required
required_by_date?: string; // ISO date "YYYY-MM-DD"
ordered_by?: string;
items: PurchaseOrderItemPayload[];
file: {
filename: string;
contentType: "application/pdf" | "image/jpeg" | "image/png" | "image/webp";
base64: string; // no data: prefix
};
raw_ocr: Record<string, unknown>;
};
export type PurchaseOrderItemPayload = {
material_code?: string;
cost_code?: string;
description: string;
qty: number;
unit?: string;
rate: number;
disc_percent?: number;
vat_percent?: number;
};
Validation rules (in src/lib/purchase-order-schema.ts)
po_number,project_code,supplier— non-empty strings.items— non-empty array; each item hasdescription,qty > 0,rate >= 0.file.base64— valid base64, decoded size ≤ 20 MB.file.contentType— must be in the allowlist.- On failure: route returns
400 { error, details }.
Site matching
In /api/purchase-orders/ingest (implemented as a small helper in src/lib/purchase-order-schema.ts):
- Exact match — query
pbAdmin().collection("sites").getFullList({ filter: \project_code = "${project_code}"` })`. - Starts-with fallback — if no exact match, query with
filter: \project_code ~ "${project_code}"`(PocketBase's~` operator is case-insensitive contains) and prefer the shortest result. - No match — leave
siteempty, setraw_ocr_data.unmatched_project_code = true. PO is still created (user can link manually later). - Multiple matches at any stage → use the first. Acceptable for v1; flagged as known limitation. A warning is added to the response so n8n logs it.
- The denormalized
project_codefield on the PO record is always populated from the payload (list view works even before site is matched).
Derived fields
Written by the ingest route, not trusted from n8n:
items_count=items.length.total_value= sum ofqty * rate * (1 - disc_percent/100) * (1 + vat_percent/100)per item, rounded to 2 decimals.purchase_order_file= reconstructed fromfile.base64→Buffer→Blob→ passed to PocketBase asFile.raw_ocr_data={ ...raw_ocr, ingested_at: ISO timestamp, matched_site_id: string | null }.is_verified=false(manager verifies via existingVerifyButton).
Response back to n8n
Success:
{ "ok": true, "id": "<pocketbase_record_id>", "po_number": "PO-1234", "warnings": ["site_unmatched"] }
Errors:
400 { error, details }— malformed payload.401 { error: "unauthorized" }— missing or wrong secret.409 { error: "duplicate_po_number", existing_id }— collision.500 { error, step }—step∈"decode_file" | "lookup_site" | "create_po" | "create_items".
Files
New
| File | Purpose |
|---|---|
src/components/upload-purchase-order-button.tsx |
Upload button + dialog. Mirrors upload-delivery-note-button.tsx. |
src/app/api/purchase-orders/upload/route.ts |
Forwards file to n8n, returns 202. |
src/app/api/purchase-orders/ingest/route.ts |
Webhook ingress; verifies secret; writes to PocketBase. |
src/lib/purchase-order-schema.ts |
Shared payload types + runtime validator + site-matching helper. |
docs/n8n/purchase-order-ingest.md |
Handoff doc for building the n8n workflow. |
Modified
| File | Change |
|---|---|
src/app/dashboard/purchasing/page.tsx |
Add <UploadPurchaseOrderButton /> to Topbar's right slot, next to the existing <UploadDeliveryNoteButton />. |
.env.local.example |
Add N8N_PO_INGEST_URL= and N8N_INGEST_SECRET= with inline comments. |
Unchanged
src/lib/pb.ts,src/lib/pb-types.ts,scripts/pb-bootstrap-schema.py— schema already supports everything.src/components/upload-delivery-note-button.tsx— left untouched.src/components/layout/sidebar.tsx— Topbar already supports therightslot.src/app/dashboard/purchasing/purchasing-view.tsx— empty-state copy at line 266 still accurate.
UX
Upload button (UploadPurchaseOrderButton)
Mirrors UploadDeliveryNoteButton:
"use client"directive.- Phase state machine:
idle | uploading | done | error. - Radix
Dialog(max-w-xl), drop zone, submit, success panel. accept="application/pdf,image/jpeg,image/png,image/webp".- Client-side size check (
file.size <= 20_000_000). - Submits
FormDatato/api/purchase-orders/upload. - Success: "Uploaded — being processed by n8n. The PO will appear here once extraction finishes." Stays open until the user clicks "Done" (no auto-close). No refresh button —
force-dynamicre-fetches on next nav. - Error: red panel with server's
errormessage.
Async UX rationale
OCR can take 30+ seconds. Blocking the user is bad. They get instant "processing" feedback and can navigate. The new PO appears on their next visit.
Error handling
User-facing
| Scenario | Behaviour |
|---|---|
| File too big or wrong type | Inline error before submit. |
| n8n webhook unreachable | Red error panel: "Upload failed — n8n ingestion service unreachable." |
| 202 success | Success panel (see above). |
| Non-202 | Red error panel with server message. |
n8n callback
| Scenario | Route behaviour |
|---|---|
| Bad secret header | 401. Log warning. No PB writes. |
| Malformed payload | 400. n8n logs + retries per its policy. |
Duplicate po_number |
409. n8n logs; no overwrite. Route is idempotent. |
| Site not matched | 200 with warnings. PO still created. |
| PB write fails mid-way | 500 { error, step }. n8n retries. |
n8n retry policy (documented in handoff)
- Retry
500and408up to 3× with exponential backoff (10s, 30s, 90s). - Do not retry
400or401— configuration errors. - On
409, log + dead-letter (n8n "Error" branch).
Observability
Both routes log with [po-upload] / [po-ingest] prefixes:
[po-upload]success:{ filename, size, contentType, n8n_status, duration_ms }.[po-ingest]success:{ po_number, project_code, matched_site_id, items_count, total_value, duration_ms, warnings }.[po-ingest]failure: fullerror+step.
Environment variables
Add to .env.local (and document in .env.local.example):
# n8n webhook URL for the PO ingestion trigger.
# Server-only (no NEXT_PUBLIC_ prefix) — never exposed to the browser.
N8N_PO_INGEST_URL=https://n8n.your-host.example/webhook/purchase-order-upload
# Shared secret n8n must send in the x-ingest-secret header when calling
# /api/purchase-orders/ingest. Generate with: openssl rand -hex 32
N8N_INGEST_SECRET=<32-byte-hex-string>
Testing
Unit / route tests (Vitest)
purchase-order-schema.tsvalidator:- Valid payload → no errors.
- Missing required field → specific error.
- Oversized file (decoded >20 MB) → specific error.
- Bad base64 → specific error.
- Empty
itemsarray → specific error.
- Site-matching helper:
- Exact match → returns id.
- Starts-with match → returns id.
- No match → returns null + sets warning.
Integration / smoke tests (manual, post-deploy)
- Upload a real PO PDF via the button → confirm n8n receives it (check n8n execution history).
- With n8n mocked via a test webhook returning a canned payload, POST a valid payload to
/api/purchase-orders/ingestwith the correct secret → confirmpurchase_orders+purchase_order_itemsrows appear in PocketBase. - POST with wrong secret → expect
401. - POST with duplicate
po_number→ expect409. - POST with unmatched
project_code→ expect200withwarnings: ["site_unmatched"]; verify PO created withsiteempty andraw_ocr_data.unmatched_project_code = true. - After ingest, reload
/dashboard/purchasing→ new PO appears with correcttotal_valueanditems_count.
Out of test scope (v1)
- OCR accuracy (n8n's territory).
- PocketBase schema changes (none being made).
- Realtime UX (not being built).