Merge branch 'feat/pos-delivery-notes-and-scan'

Account sales at the POS now raise a real delivery note instead of a
device-rendered thermal slip, every delivery note prints a scannable QR of
its number, and a standalone /scan PWA turns that QR into reprint /
capture-signed-copy / ready-to-invoice.

Carries two fixes found on the way:
  - the PDF pipeline rendered through a JS string, which left every
    document uncompressed and silently dropped any raster image
  - printing a PDF iframe printed Chrome's empty pdf_embedder shell,
    i.e. a dialog of blank pages

Migration 2026-10-03 (pos_sales.delivery_note) is idempotent and has
already been applied to live PocketBase.
This commit is contained in:
Dave 2026-10-04 13:17:33 +02:00
commit 8f993d5fcd
37 changed files with 17006 additions and 14048 deletions

27668
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
"pb:migrate:order-status-in-stock": "echo 'deprecated: orders.status was dropped 2026-06-27. No-op.' && exit 0",
"pb:migrate:orders-rep": "npx tsx scripts/migrations/2026-06-26-add-orders-rep.ts",
"pb:migrate:pos-slip-layouts": "npx tsx scripts/migrations/2026-09-04-create-pos-slip-layouts.ts",
"pb:migrate:pos-sales-delivery-note": "npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts",
"pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts",
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
@ -88,6 +89,7 @@
"pdf-parse": "^2.4.5",
"pdfjs-dist": "^5.4.296",
"pocketbase": "^0.26.9",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
@ -107,12 +109,14 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jsdom": "^29.1.1",
"jsqr": "^1.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite-tsconfig-paths": "^6.1.1",

27
public/manifest-scan.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "WFDASH Delivery Scanner",
"short_name": "DN Scanner",
"description": "Scan the QR on a delivery note to reprint it, capture the signed copy, or move it to ready to invoice",
"start_url": "/scan",
"scope": "/",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#4C96D7",
"orientation": "portrait",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"categories": ["business", "productivity", "utilities"],
"prefer_related_applications": false
}

View file

@ -291,12 +291,32 @@ window.reprint = function(orderId){
qty: it.qty,
product_id: it.stock_item,
})),
// The API returns the customer as a relation id only (no name
// expansion in the sales list). Pass null so receipt.html's
// `if (s.customer)` guard skips the line — better than printing
// "Customer: undefined". If we need the name, the user can add
// a /api/v1/pos/sales/:id endpoint that expands it.
customer: null,
// An account sale has a real delivery note behind it (raised
// server-side inside `checkoutSale`). Carry the link across so
// receipt.html can show the DN number and reprint the A4 original
// — without this the reprint screen said "Not created" for a DN
// that exists, and offered no way to print it.
deliveryNote: (s.expand && s.expand.delivery_note && s.expand.delivery_note.delivery_note_number)
? {
id: s.delivery_note,
delivery_note_number: s.expand.delivery_note.delivery_note_number,
}
: null,
// Expanded by GET /api/v1/pos/sales (customer + its company +
// the linked DN). Needed so the reprinted DN card names the
// account the client is about to sign for.
customer: s.expand && s.expand.customer
? {
id: s.expand.customer.id,
name: s.expand.customer.customer,
// `customer.company` is a relation id; the NAME lives on the
// nested expand. Without it the card showed "rpqogiu03d97lcv"
// where a company belongs.
company: (s.expand.customer.expand
&& s.expand.customer.expand.company
&& s.expand.customer.expand.company.company) || '',
}
: null,
synced: true,
serverRef: s.id,
};

View file

@ -883,6 +883,14 @@ async function confirmSale(){
const j = await r.json();
sale.synced = true;
sale.serverRef = j.sale && j.sale.id;
// The server raises the delivery note for an `account` sale and
// hands it back here. Carried onto the local sale so
// receipt.html can print the A4 original instead of the old
// 80mm thermal slip. Absent for cash / card / EFT, and absent
// for an account sale whose DN creation failed — in which case
// the operator is told to raise one by hand.
if (j.deliveryNote) sale.deliveryNote = j.deliveryNote;
if (j.deliveryNoteError) sale.deliveryNoteError = j.deliveryNoteError;
} else {
// The route returns JSON on every non-2xx path, so this should
// always parse. Overselling is allowed on purpose (no stock

View file

@ -116,6 +116,12 @@ input,select{font:inherit;color:inherit}
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
.dn-toggle input{width:16px;height:16px}
@media print{.dn-toggle{display:none}}
/* System-DN state note. Green = printable, amber = waiting on the
network, red = a human has to raise the DN by hand. */
.dn-note{margin:0;font-size:13px;line-height:1.45;padding:11px 12px;border-radius:10px;border:1px solid transparent}
.dn-note-linked{background:rgba(16,133,101,.08);border-color:rgba(16,133,101,.28);color:var(--fg)}
.dn-note-pending{background:rgba(200,140,20,.10);border-color:rgba(200,140,20,.32);color:var(--fg)}
.dn-note-failed{background:rgba(201,42,42,.10);border-color:rgba(201,42,42,.32);color:var(--fg)}
.dn-sign{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding-top:18px;margin-top:6px;border-top:1px dashed var(--muted)}
.dn-sign > div{padding-top:8px;border-top:1.5px solid var(--fg);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);text-align:center}
@ -324,7 +330,7 @@ input,select{font:inherit;color:inherit}
</button>
<button class="btn acct" data-od-id="print-delivery" id="printDnBtn" style="display:none" onclick="printDelivery()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 4h11l3 3v13H5zM9 9h6M9 13h6M9 17h4"/></svg>
Print delivery note
Print delivery note (A4)
</button>
<button class="btn" data-od-id="new-sale" onclick="location.href='pos.html'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>
@ -363,10 +369,18 @@ if(!sale){
if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
if(sale.payment==='account'){
// Stashed so the "Include prices" checkbox can re-render in place.
window.__lastSale = sale;
document.getElementById('deliveryWrap').style.display='block';
document.getElementById('printDnBtn').style.display='flex';
// An account sale never gets a paper slip. The goods leave on the
// delivery note and that is the document the client signs, so
// offering "Print slip" here just invites someone to print the
// wrong thing at the counter. The till slip stays for cash / card
// / EFT, which are the tenders where payment is actually taken.
const slipBtn = document.getElementById('printBtn');
if (slipBtn) slipBtn.style.display = 'none';
// The print button only makes sense once a DN actually exists.
// A queued (offline) sale or a failed creation still shows the
// card with its state, but has nothing to print yet.
if(sale.deliveryNote) document.getElementById('printDnBtn').style.display='flex';
renderDeliveryNote(sale);
}
}
@ -465,44 +479,61 @@ function renderReceipt(s){
document.getElementById('receiptCard').innerHTML = html;
}
// The system delivery note. The account sale raised this server-side
// inside `checkoutSale`; this card just shows the number the operator
// quotes to the client and hands them the A4 print.
//
// The old 80mm thermal delivery-note slip is gone from this path by
// design — the client gets the same document the office keeps, printed
// on the office printer. The thermal slip still prints for cash / card
// / EFT, which never reach this function.
function renderDeliveryNote(s){
const date = new Date(s.createdAt);
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
// Some customers must not see prices on the slip the driver carries;
// others want them. Defaults from the letterhead's saved setting
// (Settings → Edit slip layout); the checkbox is a per-print override.
const toggle = document.getElementById('dnShowPrices');
const letterheadIdDn = s.letterhead && s.letterhead.id;
const showPrices = toggle ? !!toggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdDn);
const lines = s.items.map(it=>{
const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
return `<div class="dn-line">
<div class="nm">${esc(it.description||it.sku)}</div>
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
${price}
</div>`;
}).join('');
const companyLine = s.customer && s.customer.company
? `<div class="v">${esc(s.customer.company)}</div>` : '';
const customerName = (s.customer && s.customer.name) || '—';
const companyName = (s.customer && s.customer.company) || '';
// Three states, and the operator has to be able to tell them apart
// at a glance:
// linked — the DN exists, print it
// pending — the sale is still in the offline queue; the DN is
// raised automatically on the next reconnect
// failed — the sale banked but the DN could not be created, so a
// human has to raise it from the CRM
let state, numberHtml, noteHtml;
if (s.deliveryNote) {
state = 'linked';
numberHtml = `<div class="v" style="font-size:17px">${esc(s.deliveryNote.delivery_note_number)}</div>`;
noteHtml = 'Print two copies for the client to sign — one to keep, one to bring back to the office.';
} else if (s.synced) {
state = 'failed';
numberHtml = '<div class="v" style="font-size:17px">Not created</div>';
noteHtml = esc(s.deliveryNoteError
|| 'The sale was recorded but no delivery note was created. Raise one by hand from the CRM.');
} else {
state = 'pending';
numberHtml = '<div class="v" style="font-size:17px">Pending</div>';
noteHtml = 'This sale is saved on the till. The delivery note is raised automatically once the connection is back — pick it up from the CRM, not the thermal printer.';
}
const html = `
<div class="dn-head">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
<div class="nm">
<div class="t">Delivery note</div>
<div class="s">${esc((s.customer && s.customer.company) || (s.customer && s.customer.name) || '—')}</div>
</div>
<div class="dn-head">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
<div class="nm">
<div class="t">Delivery note</div>
<div class="s">${esc(companyName || customerName)}</div>
</div>
<div class="dn-meta">
<div><div class="k">Customer</div><div class="v">${esc((s.customer && s.customer.name)||'—')}</div>${companyLine}</div>
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
</div>
<label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
<div class="dn-items">${lines}</div>
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
</div>
<div class="dn-meta">
<div><div class="k">Customer</div><div class="v">${esc(customerName)}</div>${companyName?`<div class="v">${esc(companyName)}</div>`:''}</div>
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
<div><div class="k">DN number</div>${numberHtml}</div>
<div><div class="k">Items</div><div class="v">${s.items.length}</div></div>
</div>
<p class="dn-note dn-note-${state}">${noteHtml}</p>
`;
document.getElementById('deliveryCard').innerHTML = html;
window.__dnState = state;
}
window.renderDeliveryNote = renderDeliveryNote;
@ -535,21 +566,15 @@ async function printThermalNetwork(settings){
// hands to the customer at drop-off, not a "paid" receipt. Cash
// / card / EFT get the standard sale slip.
let xml, successLabel;
if (sale.payment === 'account') {
const letterheadId = sale.letterhead && sale.letterhead.id;
const deliveryBlocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'delivery');
const dnToggle = document.getElementById('dnShowPrices');
const showPricing = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadId);
const dnLogo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocks, showPricing, dnLogo);
successLabel = 'Delivery note printed · ';
} else {
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const letterheadId = sale.letterhead && sale.letterhead.id;
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
successLabel = 'Printed · ';
}
// Always the standard sale slip. Account sales no longer print a
// thermal delivery note — the system A4 DN is printed by
// `printDelivery()` instead, so this button is the till slip for
// every tender type.
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const letterheadId = sale.letterhead && sale.letterhead.id;
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
successLabel = 'Printed · ';
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = successLabel + settings.ip;
}catch(e){
@ -594,11 +619,10 @@ async function printThermalBluetooth(){
// Account sales print a delivery note (received-by / driver lines,
// no payment line). Everything else prints the standard sale slip.
const letterheadIdBt = sale.letterhead && sale.letterhead.id;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBt);
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'delivery'))
: buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
// Standard sale slip for every tender — see the note on the
// network path above. Account sales print the system A4 DN
// through `printDelivery()` instead.
const text = buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
const CHUNK = 80;
const enc = new TextEncoder();
for(let i=0;i<text.length;i+=CHUNK){
@ -606,7 +630,7 @@ async function printThermalBluetooth(){
await btChar.writeValueWithoutResponse(enc.encode(slice));
await new Promise(r=>setTimeout(r,30));
}
status.textContent = (sale.payment === 'account' ? 'Delivery note printed · ' : 'Printed · ') + (btDevice.name||'printer');
status.textContent = 'Printed · ' + (btDevice.name||'printer');
}catch(e){
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
else status.textContent = 'Print failed: ' + e.message;
@ -691,7 +715,7 @@ function saleEscPosRenderers(s){
}
// `blocks` is the resolved block list for 'sale'. Returns a plain
// string (matching buildEscPosDelivery) — the caller runs it through
// string — the caller runs it through
// TextEncoder before writing to the Bluetooth characteristic. (Note:
// this used to manually pack a Uint8Array here, which the caller then
// fed into TextEncoder.encode() — encoding a Uint8Array as if it were
@ -704,132 +728,78 @@ function buildEscPos(s, blocks){
return ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
}
// Prints the SYSTEM delivery note — the A4 original the office keeps —
// by opening it in a new tab and letting the operator send it to the
// office network printer from there (set Copies to 2 for the client).
//
// This replaced the old 80mm thermal delivery-note print. The thermal
// slip is still the path for cash / card / EFT via `printThermal`,
// and its button is hidden for account sales entirely.
async function printDelivery(){
if(!sale) return;
const dn = sale.deliveryNote;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
if(btn) btn.disabled = true;
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
const printerSettings = window.EposPrint.getSettings();
if(printerSettings.type === 'network'){
if(!printerSettings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
if(!dn){
if(status) status.textContent = 'No delivery note on this sale yet.';
if(btn) btn.disabled = false;
return;
}
if(status) status.textContent = 'Fetching ' + dn.delivery_note_number + '…';
let url = null;
try{
// Fetched through posFetch because the route sits behind
// assertPosAuth — an <iframe src> or a plain window.open() to the
// route cannot carry the bearer header, so the bytes come in as a
// blob first and the blob URL is what gets opened.
const r = await window.posFetch('/delivery-note/' + encodeURIComponent(dn.delivery_note_number) + '/pdf');
if(!r.ok){
let msg = 'HTTP ' + r.status;
try{ const j = await r.json(); if(j && j.error) msg = j.error; }catch(_){}
throw new Error(msg);
}
const blob = await r.blob();
url = URL.createObjectURL(blob);
// Open the blob in a NEW TAB rather than calling print() on a
// hidden iframe.
//
// An earlier version of this built a 1x1 iframe and called
// `contentWindow.print()`, which looks right and is not. Chrome
// backs ANY pdf — src URL or blob URL — with its internal
// `pdf_embedder` extension, so the frame's document is an empty
// shell:
//
// <html><head><link rel="stylesheet"
// href="chrome-extension://…/pdf_embedder.css"></head>
// <body></body></html>
//
// `print()` then prints that empty body: a dialog of blank pages
// with no error to explain why. Verified by reading the iframe's
// document out of a live page. A new tab gets Chrome's real PDF
// viewer, whose own Print button works.
const win = window.open(url, '_blank');
if(!win){
errBox.classList.add('err');
btn.disabled = false;
status.textContent = 'Pop-up blocked — allow pop-ups for this site, or use the office computer to open the delivery note from WFDASH.';
return;
}
const letterheadIdNet = sale.letterhead && sale.letterhead.id;
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdNet);
try{
const deliveryBlocksNet = window.PosSlipLayout.getBlocksForPrint(letterheadIdNet, 'delivery');
const dnLogoNet = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocksNet, showPricesNet, dnLogoNet);
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
return;
}
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const letterheadIdBtDn = sale.letterhead && sale.letterhead.id;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBtDn);
const text = buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBtDn, 'delivery'));
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
try{
for(let i=0;i<bytes.length;i+=80){
await btChar.writeValueWithoutResponse(bytes.slice(i, i+80));
await new Promise(r=>setTimeout(r,30));
}
status.textContent = 'Delivery note printed.';
status.textContent = dn.delivery_note_number + ' opened in a new tab — press Print there, and set Copies to 2 for the client.';
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
if(status) status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
// Keep the blob alive long enough for the new tab to load it.
if(url) setTimeout(() => { URL.revokeObjectURL(url); }, 60000);
if(btn) btn.disabled = false;
}
}
// Mirrors the sale slip's header: same 2x-size title and the same
// separate business_info block, so a delivery note reads as the same
// company's paperwork. (Business info used to be baked into this
// transport's header_title while the network XML version omitted it
// entirely — the two transports have now been reconciled on the
// sale-slip shape, and business_info is its own reorderable block.)
function deliveryEscPosRenderers(s, showPrices){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
return {
// Web Bluetooth GATT printing has no raster-image support in this
// app — this block intentionally prints nothing on this transport.
logo: () => '',
header_title: (block) => {
const title = block.text || 'WFSALES POS';
return ESC+'a'+E(1) + ESC+'!'+E(0x30) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
},
business_info: () => {
let out = ESC+'a'+E(1);
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
if(bizName) out += bizName + '\n';
if(s.letterhead){
if(s.letterhead.address) out += s.letterhead.address.split('\n')[0]+'\n';
if(s.letterhead.phone) out += 'Tel: '+s.letterhead.phone+'\n';
if(s.letterhead.vat_number) out += 'VAT: '+s.letterhead.vat_number+'\n';
}
return out + ESC+'a'+E(0);
},
customer: () => {
let out = '';
if(s.customer && s.customer.company) out += 'Company: ' + s.customer.company + '\n';
out += 'Customer: ' + (s.customer && s.customer.name || '—') + '\n';
return out;
},
date: () => {
const d = new Date(s.createdAt);
return 'Date: ' + d.toLocaleDateString('en-ZA')+'\n';
},
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku)+'\n';
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
out += showPrices ? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n` : `${qtyLine}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
if(showPrices) out += 'TOTAL: ' + fmt(s.total) + '\n';
return out;
},
signature: () => 'Received by: __________ Driver: __________\n',
};
}
// `blocks` is the resolved block list for 'delivery'. Returns a
// plain string (see buildEscPos's comment on why — TextEncoder needs
// a string, not a Uint8Array).
function buildEscPosDelivery(s, showPrices, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const renderers = deliveryEscPosRenderers(s, showPrices);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + E(0x1D)+'V'+E(0x41)+E(0x10);
}
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';

View file

@ -1,5 +1,10 @@
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v26";
// v28 — account sales can now reprint their delivery note from
// history.html (the sale's linked DN is now carried into the reprint
// screen), and the "Print slip" button is hidden for account sales —
// the delivery note is the only document that tender produces.
// Bumped so tills evict the stale app shell.
const CACHE = "pos-v28";
const APP_SHELL = [
'./',
'./index.html',

View file

@ -0,0 +1,108 @@
/* eslint-disable */
/**
* Migration: add `delivery_note` to `pos_sales`.
*
* ## Why
*
* The POS used to render a delivery note entirely on the device
* (`renderDeliveryNote` in `public/pos/receipt.html`) and print it on
* the 80mm thermal printer. Nothing reached the server, so the DN the
* driver carried had no record behind it.
*
* Now an `account` tender creates a real `delivery_notes` record
* server-side, inside `checkoutSale`. This field is the idempotency
* anchor for that: the till's offline queue re-posts `/checkout` with
* the same `client_ref`, and without this field a replay would create a
* SECOND delivery note for one physical sale.
*
* `checkoutSale` reads this on the replay path — if it's set, the
* existing DN is returned untouched; if it's blank the creation is
* attempted (covers the case where the sale banked but DN creation
* failed).
*
* ## What it does
*
* Adds one field:
*
* - `delivery_note` (relation → delivery_notes, single, optional)
* The system delivery note raised for this account sale.
*
* Only set on `payment_method = "account"` sales that have a customer
* picked. Cash / card / EFT sales leave it blank forever.
*
* Idempotent — safe to re-run.
*
* Run with:
* npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[pos-sales-delivery-note] Authenticated against ${url}`);
const deliveryNotes = await pb.collections.getOne("delivery_notes");
const posSales = await pb.collections.getOne("pos_sales");
if (posSales.fields.some((f: { name: string }) => f.name === "delivery_note")) {
console.log("[pos-sales-delivery-note] pos_sales.delivery_note already present — nothing to do.");
return;
}
await pb.collections.update(posSales.id, {
fields: [
...posSales.fields,
{
name: "delivery_note",
type: "relation",
required: false,
presentable: false,
// PB stores relation options FLAT at the top level of the
// field object, not nested under `options` — verified
// against the live schema by inspecting
// `orders.potential_quote`. The nested form is rejected
// with "fields.N.collectionId Cannot be blank."
collectionId: deliveryNotes.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
},
],
});
console.log("[pos-sales-delivery-note] Added pos_sales.delivery_note.");
}
main().catch((err) => {
console.error("[pos-sales-delivery-note] FAILED:", err);
process.exit(1);
});

View file

@ -1,6 +1,6 @@
import React from "react";
import { NextRequest, NextResponse } from "next/server";
import { pdf, type DocumentProps } from "@react-pdf/renderer";
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { buildPrintBundle } from "@/app/(app)/invoicing/print-bundle";
@ -183,10 +183,13 @@ async function handlePrint(input: { deliveryNoteNumber: string; origin: string }
}),
};
});
const slipDoc = PickingSlipPdf({ header: slipHeader, lines: slipLines });
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
const slipString = await slipInstance.toString();
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
// renders uncompressed and re-encodes the PDF as latin1, which mangles
// binary streams (raster images arrive embedded but unpainted). See the
// note at the top of `src/lib/pdf/render.tsx`.
const slipBytes = new Uint8Array(
await renderToBuffer(PickingSlipPdf({ header: slipHeader, lines: slipLines }) as React.ReactElement<DocumentProps>),
);
// Same bundle shape as the invoice print: duplicate the DN so the
// operator gets an office copy + customer copy in one print, then

View file

@ -4,9 +4,10 @@ const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock, f
const fetchMock = vi.fn();
const getFullList = vi.fn();
const buildPrintBundle = vi.fn();
const pdfMock = vi.fn(() => ({
toString: vi.fn().mockResolvedValue("SLIP_PDF"),
}));
// The route renders the picking slip via `renderToBuffer()`, not
// `pdf(doc).toString()` — see the note at the top of
// `src/lib/pdf/render.tsx`. Returns real bytes.
const pdfMock = vi.fn().mockResolvedValue(Buffer.from("SLIP_PDF"));
const pickingSlipPdfMock = vi.fn((_props: unknown) => "PICKING_SLIP_DOC");
// Mirror the PB SDK's files.getURL: the caller passes
// { collectionId, id } and a filename, the SDK returns
@ -43,7 +44,7 @@ vi.mock("@/app/(app)/invoicing/print-bundle", () => ({
}));
vi.mock("@react-pdf/renderer", () => ({
pdf: pdfMock,
renderToBuffer: pdfMock,
Document: () => null,
Page: () => null,
Text: () => null,

View file

@ -1,6 +1,6 @@
import React from "react";
import { NextRequest, NextResponse } from "next/server";
import { pdf, type DocumentProps } from "@react-pdf/renderer";
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { buildPrintBundle } from "@/app/(app)/invoicing/print-bundle";
@ -164,10 +164,15 @@ async function handlePrintBundle(input: { invoiceNumber: string }) {
};
});
const slipDoc = PickingSlipPdf({ header, lines });
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
const slipString = await slipInstance.toString();
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
// renders uncompressed and re-encodes the PDF as latin1, which mangles
// binary streams (raster images arrive embedded but unpainted). See the
// note at the top of `src/lib/pdf/render.tsx`.
const slipBytes = new Uint8Array(
await renderToBuffer(
PickingSlipPdf({ header, lines }) as React.ReactElement<DocumentProps>,
),
);
const combined = await buildPrintBundle(invoiceBytes, slipBytes);
const out = Buffer.from(combined);

View file

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from "next/server";
import { assertBrowserSession } from "@/lib/server/browser-session";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/delivery-notes/resolve?number=DN26100401
*
* Turns a scanned delivery note NUMBER into the record the rest of the
* API speaks in (a 15-char PB id).
*
* The QR on the printed note carries the human number, because that is
* what the operator reads out and what the paper already shows. Every
* existing route — detail, signed-copy, mark-invoiced, customer-po —
* is keyed on the PB id, so the scanner needs this hop. It is the only
* place a number becomes an id, which keeps that translation in one
* auditable spot instead of spreading a guess-the-key lookup over the
* client and every action button.
*
* Returns a compact summary rather than the full detail read model:
* the scanner card needs the header, the line count and the status (to
* decide which ladder step to offer), not the quote history.
*
* Errors:
* - 401 no browser session
* - 400 missing / malformed `number`
* - 404 no delivery note with that number
*/
export async function GET(req: NextRequest) {
try {
await assertBrowserSession(req);
} catch (error) {
const message = error instanceof Error ? error.message : "Unauthorized";
return NextResponse.json({ error: message }, { status: 401 });
}
const raw = (req.nextUrl.searchParams.get("number") ?? "").trim().toUpperCase();
if (!raw) {
return NextResponse.json({ error: "number is required" }, { status: 400 });
}
// `DN` + 8-10 digits. Matches the validation in
// `src/lib/scan/qr-detect.ts`; the scanner checks first, this is the
// server-side belt so a hand-rolled request can't put a PB filter
// string in front of an unvalidated value.
if (!/^DN\d{8,10}$/.test(raw)) {
return NextResponse.json(
{ error: `"${raw}" is not a delivery note number (expected DN followed by 8-10 digits).` },
{ status: 400 },
);
}
try {
const pb = await getAdminPocketBase();
// The number is regex-validated to DN + digits only, so it cannot
// contain a quote or a PB filter operator.
// Same collection name the service uses; it is a module-local
// const there and not exported, so we repeat the literal.
const page = await pb.collection("delivery_notes").getList<Record<string, unknown>>(
1,
1,
{
filter: `delivery_note_number = "${raw}"`,
expand: "customer,company",
},
);
const dn = page.items[0];
if (!dn) {
return NextResponse.json(
{ error: `No delivery note found for ${raw}.` },
{ status: 404 },
);
}
const customer = (dn as { expand?: { customer?: { customer?: string } } }).expand?.customer;
const company = (dn as { expand?: { company?: { company?: string } } }).expand?.company;
const itemCount = await pb
.collection("order_items")
.getList(1, 1, { filter: `delivery_note_number = "${raw}"` })
.then((p) => p.totalItems ?? p.items.length)
.catch(() => 0);
return NextResponse.json({
id: dn.id,
delivery_note_number: dn.delivery_note_number,
status: dn.status,
customer_name: customer?.customer ?? "",
company_name: company?.company ?? "",
customer_po: dn.customer_po ?? "",
delivery_date: dn.delivery_date ?? "",
signed_copy: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "",
item_count: itemCount,
created: dn.created,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to resolve delivery note";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -38,6 +38,7 @@ describe("POST /api/v1/pos/checkout", () => {
items: [],
stockErrors: [],
duplicate: false,
deliveryNote: null,
});
const res = await POST(
checkoutRequest({
@ -89,6 +90,7 @@ describe("POST /api/v1/pos/checkout", () => {
items: [],
stockErrors: [],
duplicate: false,
deliveryNote: null,
});
await POST(
checkoutRequest({
@ -110,6 +112,7 @@ describe("POST /api/v1/pos/checkout", () => {
items: [],
stockErrors: [],
duplicate: false,
deliveryNote: null,
});
await POST(
checkoutRequest({
@ -130,6 +133,7 @@ describe("POST /api/v1/pos/checkout", () => {
items: [],
stockErrors: [],
duplicate: true,
deliveryNote: null,
});
await POST(
checkoutRequest({

View file

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from "next/server";
import { assertPosAuth } from "@/lib/server/pos-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/pos/delivery-note/[number]/pdf
*
* Streams the A4 delivery-note PDF for a POS-raised account sale so
* the till can print it on the office printer.
*
* This exists rather than pointing the till at the CRM's
* `/api/invoicing/delivery-note-pdf` for two reasons:
*
* 1. Auth. Every `/api/v1/pos/*` route sits behind
* `assertPosAuth`, and the till's bearer key is the only
* credential it holds. The invoicing route has no auth check at
* all, so leaning on it would mean the till printing from an
* unauthenticated surface.
* 2. Lookup key. This takes the `DN26090301` number the checkout
* response handed back, so the till never has to carry a PB id.
*
* Storage model is unchanged: `createAdHocDeliveryNote` uploads the
* rendered PDF to every `order_items.order_item_attachements` row
* stamped with the DN number, so we find the token the same way the
* invoicing route does.
*
* Errors:
* - 401 when POS_API_KEY is missing/wrong
* - 404 unknown DN number, or a DN with no saved PDF on file
* - 502 when the upstream PocketBase file read fails
*/
type OrderItemRecord = {
id: string;
delivery_note_number?: string;
order_item_attachements?: unknown;
};
/**
* PB's multi-file fields come back as a bare string when exactly one
* file is attached and as an array otherwise. `createAdHocDeliveryNote`
* writes one PDF per item, so both shapes are normal in the wild.
*/
function findPdfToken(
items: OrderItemRecord[],
): { itemId: string; token: string } | null {
for (const item of items) {
const raw = item.order_item_attachements;
if (typeof raw === "string" && raw.length > 0) {
return { itemId: item.id, token: raw };
}
if (Array.isArray(raw)) {
// Prefer a .pdf entry — a DN item can also carry a supplier
// invoice scan or a counter-sale photo, and printing one of
// those as the delivery note would be worse than a 404.
const pdf = raw.find((t) => typeof t === "string" && t.toLowerCase().endsWith(".pdf"));
const token = pdf ?? raw.find((t) => typeof t === "string" && t.length > 0);
if (typeof token === "string" && token.length > 0) {
return { itemId: item.id, token };
}
}
}
return null;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ number: string }> },
) {
try {
assertPosAuth(req);
} catch (err) {
const message = err instanceof Error ? err.message : "Unauthorized";
return NextResponse.json({ error: message }, { status: 401 });
}
const { number: rawNumber } = await params;
const deliveryNoteNumber = (rawNumber || "").trim();
if (!deliveryNoteNumber) {
return NextResponse.json({ error: "Missing delivery note number" }, { status: 400 });
}
try {
const pb = await getAdminPocketBase();
const escaped = deliveryNoteNumber.replace(/"/g, '\\"');
const items = await pb.collection("order_items").getFullList<OrderItemRecord>({
filter: `delivery_note_number = "${escaped}"`,
});
if (items.length === 0) {
return NextResponse.json(
{ error: `No delivery note found for ${deliveryNoteNumber}` },
{ status: 404 },
);
}
const found = findPdfToken(items);
if (!found) {
return NextResponse.json(
{ error: `No saved PDF for ${deliveryNoteNumber}` },
{ status: 404 },
);
}
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const fileUrl = `${base}/api/files/order_items/${found.itemId}/${encodeURIComponent(found.token)}`;
const fileRes = await fetch(fileUrl);
if (!fileRes.ok) {
return NextResponse.json(
{ error: `Upstream PocketBase file read failed (HTTP ${fileRes.status})` },
{ status: 502 },
);
}
const buf = Buffer.from(await fileRes.arrayBuffer());
return new NextResponse(buf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${deliveryNoteNumber}.pdf"`,
// The till prints straight after checkout; a cached PDF would
// be a stale reprint of a re-raised note.
"Cache-Control": "private, no-store",
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -1,11 +1,13 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import React from "react";
const { pdfMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoisted(() => {
const { renderToBufferMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoisted(() => {
return {
pdfMock: vi.fn(() => ({
toString: vi.fn().mockResolvedValue("STOCK_PICKING_SLIP"),
})),
// The route renders via `renderToBuffer()` (module-level helper),
// not `pdf(doc).toString()` — see the note at the top of
// `src/lib/pdf/render.tsx` for why the toString path was removed.
// It returns real bytes, so the mock hands back a real Buffer.
renderToBufferMock: vi.fn().mockResolvedValue(Buffer.from("STOCK_PICKING_SLIP")),
pickingSlipPdfMock: vi.fn((_props: unknown) => "STOCK_PICKING_SLIP_DOC"),
getFirstListItem: vi.fn(),
getFullList: vi.fn(),
@ -13,7 +15,7 @@ const { pdfMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoiste
});
vi.mock("@react-pdf/renderer", () => ({
pdf: pdfMock,
renderToBuffer: renderToBufferMock,
Document: () => null,
Page: () => null,
Text: () => null,
@ -56,7 +58,7 @@ function makeGetRequest(purchaseNumber?: string): Request {
describe("GET /api/v1/stock-purchases/picking-slip", () => {
beforeEach(() => {
pdfMock.mockClear();
renderToBufferMock.mockClear();
pickingSlipPdfMock.mockReset();
getFirstListItem.mockReset();
getFullList.mockReset();

View file

@ -1,6 +1,6 @@
import React from "react";
import { NextRequest, NextResponse } from "next/server";
import { pdf, type DocumentProps } from "@react-pdf/renderer";
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { PickingSlipPdf } from "@/app/(app)/invoicing/picking-slip";
@ -149,10 +149,15 @@ export async function GET(request: NextRequest) {
};
});
const slipDoc = PickingSlipPdf({ header, lines: slipLines });
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
const slipString = await slipInstance.toString();
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
// renders uncompressed and re-encodes the PDF as latin1, which mangles
// binary streams (raster images arrive embedded but unpainted). See the
// note at the top of `src/lib/pdf/render.tsx`.
const slipBytes = new Uint8Array(
await renderToBuffer(
PickingSlipPdf({ header, lines: slipLines }) as React.ReactElement<DocumentProps>,
),
);
const filename = `${purchase.purchase_number ?? purchaseNumber}-picking-slip.pdf`;
return new NextResponse(slipBytes, {

24
src/app/scan/layout.tsx Normal file
View file

@ -0,0 +1,24 @@
import type { Metadata, Viewport } from "next";
/**
* /scan is its own route group (outside `(app)`) so it gets a
* dedicated PWA manifest — "add to home screen" should give the
* operator a scanner, not the full CRM. Mirrors the pattern already
* used by `(counter)` for `/manifest-counter.json`.
*/
export const metadata: Metadata = {
title: "DN Scanner · WFDASH",
manifest: "/manifest-scan.json",
applicationName: "WFDASH DN Scanner",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#4C96D7",
};
export default function ScanLayout({ children }: { children: React.ReactNode }) {
return children;
}

549
src/app/scan/page.tsx Normal file
View file

@ -0,0 +1,549 @@
"use client";
/**
* /scan — delivery-note scanner.
*
* Standalone (outside the `(app)` route group) so it gets a clean
* full-bleed layout: a scanner has no business in a CRM sidebar, and
* the operator using it is often holding a phone.
*
* Flow: point the camera at the QR printed on a delivery note → the
* note is resolved → offer the three things you actually do at the
* counter: reprint, photograph the signed copy, or move it toward
* invoicing.
*
* The QR carries the human number (`DN26100401`), so the first call is
* `resolve`, which maps number → PB id. Everything after that speaks
* ids, which is what every existing route already takes.
*
* STATUS LADDER — the actions offered depend on where the note sits.
* `mark-invoiced` hard-refuses anything that isn't `po_received`, and
* `customer-po` refuses a note that isn't `sent`/`loaded`. So rather
* than offering a button that 500s, each step only appears when it is
* legal, and the card shows which one comes next:
*
* draft ──load──▶ loaded ──PO──▶ po_received ──invoice──▶ invoiced
* └─(sent via quote)──▶ sent ──PO──▶ po_received ──▶ ...
*
* `loaded` is used rather than `sent` because `draft → sent` means
* "create a quote and email it" — a side effect a warehouse scan must
* never trigger silently. `load-dn` is the honest "the van has this
* paperwork" step.
*/
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Camera,
CameraOff,
CheckCircle2,
FileText,
Loader2,
QrCode,
RefreshCw,
Upload,
X,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import {
detectScannerSupport,
parseDeliveryNoteNumber,
scanVideoFrames,
type QrDetectorKind,
} from "@/lib/scan/qr-detect";
type Resolved = {
id: string;
delivery_note_number: string;
status: string;
customer_name: string;
company_name: string;
customer_po: string;
delivery_date: string;
signed_copy: string;
item_count: number;
};
type Toast = { kind: "ok" | "err"; text: string } | null;
const STATUS_COPY: Record<string, string> = {
draft: "Draft — not yet loaded",
sent: "Sent to customer",
loaded: "Loaded — with the driver",
po_received: "Customer PO received",
invoiced: "Invoiced",
completed: "Completed",
cancelled: "Cancelled",
};
export default function ScanPage() {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const abortRef = React.useRef<AbortController | null>(null);
const fileRef = React.useRef<HTMLInputElement | null>(null);
const [support, setSupport] = React.useState<{
kind: QrDetectorKind;
camera: boolean;
secureContext: boolean;
} | null>(null);
const [cameraOn, setCameraOn] = React.useState(false);
const [manual, setManual] = React.useState("");
const [note, setNote] = React.useState<Resolved | null>(null);
const [busy, setBusy] = React.useState<string | null>(null);
const [toast, setToast] = React.useState<Toast>(null);
const [poDraft, setPoDraft] = React.useState("");
const [showPoBox, setShowPoBox] = React.useState(false);
React.useEffect(() => {
setSupport(detectScannerSupport());
}, []);
// Stop the camera on unmount. Leaving a getUserMedia stream running
// keeps the camera light on after the operator navigates away.
React.useEffect(
() => () => {
abortRef.current?.abort();
streamRef.current?.getTracks().forEach((t) => t.stop());
},
[],
);
const flash = React.useCallback((kind: "ok" | "err", text: string) => {
setToast({ kind, text });
window.setTimeout(() => setToast(null), 6000);
}, []);
const stopCamera = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setCameraOn(false);
}, []);
const api = React.useCallback(
async (path: string, init?: RequestInit) => {
const token = getPocketBase().authStore.token;
const res = await fetch(path, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: token,
},
});
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: `HTTP ${res.status}`;
throw new Error(msg);
}
return body as unknown;
},
[],
);
const lookup = React.useCallback(
async (number: string) => {
setBusy("lookup");
try {
const data = await api(
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
);
setNote(data as Resolved);
setPoDraft((data as Resolved).customer_po ?? "");
setShowPoBox(false);
stopCamera();
flash("ok", `${number} loaded`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Lookup failed";
setNote(null);
flash("err", msg);
} finally {
setBusy(null);
}
},
[api, flash, stopCamera],
);
const startCamera = React.useCallback(async () => {
setToast(null);
if (!support?.camera) {
flash("err", "This device has no camera available to the browser.");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false,
});
streamRef.current = stream;
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
video.srcObject = stream;
video.setAttribute("playsinline", "true");
await video.play();
setCameraOn(true);
const controller = new AbortController();
abortRef.current = controller;
void scanVideoFrames(
video,
canvas,
(raw) => {
const dn = parseDeliveryNoteNumber(raw);
if (dn) void lookup(dn);
else flash("err", `Scanned a code that isn't a delivery note: ${raw.slice(0, 40)}`);
},
controller.signal,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
flash("err", `Camera failed to start: ${msg}`);
}
}, [support, flash, lookup]);
const runAction = React.useCallback(
async (key: string, fn: () => Promise<void>) => {
setBusy(key);
try {
await fn();
} catch (err) {
flash("err", err instanceof Error ? err.message : "Action failed");
} finally {
setBusy(null);
}
},
[flash],
);
const status = note?.status ?? "";
// Ladder helpers — each mirrors the server's own guards so the UI
// never offers a button the API will refuse.
const canLoad = status === "draft" || status === "sent";
const canPo = status === "sent" || status === "loaded";
const canInvoice = status === "po_received";
const isDead = status === "cancelled" || status === "completed";
const nextStep = canLoad
? "Load this note for the driver"
: canPo
? "Capture the customer's PO number"
: canInvoice
? "Ready to send to Invoicing"
: isDead
? "This note is closed"
: "No action needed right now";
return (
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col gap-5 px-4 py-6">
<header className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Delivery note scanner</h1>
<p className="text-sm text-muted-foreground">
Scan the QR on a printed delivery note
</p>
</div>
{note ? (
<Button variant="outline" size="sm" onClick={() => { setNote(null); setPoDraft(""); }}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
Scan another
</Button>
) : null}
</header>
{/* ---- the note card, or the scanner ---- */}
{note ? (
<section className="flex flex-col gap-4" data-testid="scan-note">
<div className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-lg font-semibold">{note.delivery_note_number}</p>
<p className="text-sm text-muted-foreground">
{note.company_name || note.customer_name || "—"}
</p>
{note.customer_name ? (
<p className="text-sm text-muted-foreground">{note.customer_name}</p>
) : null}
</div>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold">
{STATUS_COPY[status] ?? status}
</span>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Items</dt>
<dd className="font-medium">{note.item_count}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Customer PO</dt>
<dd className="font-medium">{note.customer_po || "—"}</dd>
</div>
{note.signed_copy ? (
<div className="col-span-2">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
Signed copy
</dt>
<dd className="truncate font-medium text-emerald-700">{note.signed_copy}</dd>
</div>
) : null}
</dl>
</div>
<p className="text-sm text-muted-foreground">{nextStep}</p>
<div className="flex flex-col gap-2">
<Button
className="h-12"
onClick={() =>
runAction("reprint", async () => {
// Opens in a new tab: an <iframe> would need a
// browser PDF viewer to print from, and Chrome backs
// those with an extension that ignores print().
const w = window.open(
`/api/invoicing/delivery-note-pdf?deliveryNoteNumber=${encodeURIComponent(
note.delivery_note_number,
)}`,
"_blank",
);
if (!w) flash("err", "Pop-up blocked — allow pop-ups to reprint.");
})
}
disabled={busy !== null}
>
{busy === "reprint" ? <Loader2 className="animate-spin" /> : <FileText />}
Reprint delivery note
</Button>
<Button
variant="outline"
className="h-12"
onClick={() => fileRef.current?.click()}
disabled={busy !== null || isDead}
>
{busy === "signed" ? <Loader2 className="animate-spin" /> : <Camera />}
Photograph / upload signed copy
</Button>
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
className="hidden"
data-testid="scan-signed-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
void runAction("signed", async () => {
// NOTE: deliberately the upload-only endpoint, NOT
// /api/v1/deliveries/mark-signed-copy. That one flips
// the note to `completed` in the same write, which
// would jump a note straight past invoicing.
const fd = new FormData();
fd.append("file", file);
await api(`/api/v1/delivery-notes/${note.id}/signed-copy`, {
method: "POST",
body: fd,
});
flash("ok", "Signed copy uploaded");
await lookup(note.delivery_note_number);
});
}}
/>
{canLoad ? (
<Button
variant="secondary"
className="h-12"
onClick={() =>
runAction("load", async () => {
await api("/api/v1/deliveries/load-dn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deliveryNoteId: note.id }),
});
flash("ok", "Marked as loaded");
await lookup(note.delivery_note_number);
})
}
disabled={busy !== null}
>
{busy === "load" ? <Loader2 className="animate-spin" /> : <Upload />}
Mark as loaded (for the driver)
</Button>
) : null}
{canPo ? (
<Button
variant="secondary"
className="h-12"
onClick={() => setShowPoBox((v) => !v)}
disabled={busy !== null}
>
<QrCode className="h-4 w-4" />
Capture customer PO number
</Button>
) : null}
{showPoBox ? (
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
<Label htmlFor="scan-po" className="text-sm">
Customer PO number
</Label>
<Input
id="scan-po"
value={poDraft}
onChange={(e) => setPoDraft(e.target.value)}
placeholder="e.g. PO-88213"
/>
<div className="flex gap-2">
<Button
className="flex-1"
disabled={!poDraft.trim() || busy !== null}
onClick={() =>
runAction("po", async () => {
await api(`/api/v1/delivery-notes/${note.id}/customer-po`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customer_po: poDraft.trim() }),
});
flash("ok", "Customer PO saved");
setShowPoBox(false);
await lookup(note.delivery_note_number);
})
}
>
{busy === "po" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
Save PO
</Button>
<Button variant="ghost" onClick={() => setShowPoBox(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
{canInvoice ? (
<Button
className="h-12"
onClick={() =>
runAction("invoice", async () => {
await api(`/api/v1/delivery-notes/${note.id}/mark-invoiced`, {
method: "POST",
});
flash("ok", "Moved to ready to invoice");
await lookup(note.delivery_note_number);
})
}
disabled={busy !== null}
>
{busy === "invoice" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
Move to ready to invoice
</Button>
) : null}
</div>
</section>
) : (
<section className="flex flex-col gap-4">
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-black">
<video
ref={videoRef}
className="h-full w-full object-cover"
muted
playsInline
data-testid="scan-video"
/>
{!cameraOn ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<QrCode className="h-10 w-10 text-white/60" />
<p className="text-sm text-white/80">
{support?.camera
? "Camera is off"
: "This browser will not give the page a camera"}
</p>
</div>
) : null}
<div className="pointer-events-none absolute inset-[18%] rounded-xl border-2 border-white/70" />
</div>
<canvas ref={canvasRef} className="hidden" />
{support && !support.secureContext ? (
<p className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
This page is not on HTTPS, so the browser blocks the camera. Use the office
network&apos;s secure address, or type the number below.
</p>
) : null}
<div className="flex gap-2">
{cameraOn ? (
<Button variant="outline" className="h-12 flex-1" onClick={stopCamera}>
<CameraOff className="mr-1 h-4 w-4" />
Stop camera
</Button>
) : (
<Button
className="h-12 flex-1"
onClick={startCamera}
disabled={!support?.camera}
>
<Camera className="mr-1 h-4 w-4" />
Start camera
</Button>
)}
</div>
<div className="rounded-lg border p-3">
<Label htmlFor="scan-manual" className="text-sm">
Or type the delivery note number
</Label>
<div className="mt-1 flex gap-2">
<Input
id="scan-manual"
value={manual}
onChange={(e) => setManual(e.target.value.toUpperCase())}
placeholder="DN26100401"
className="font-mono"
data-testid="scan-manual-input"
/>
<Button
disabled={!parseDeliveryNoteNumber(manual) || busy !== null}
onClick={() => {
const dn = parseDeliveryNoteNumber(manual);
if (dn) void lookup(dn);
}}
>
{busy === "lookup" ? <Loader2 className="animate-spin" /> : null}
Go
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Works on any device, camera or not.
</p>
</div>
</section>
)}
{toast ? (
<div
data-testid="scan-toast"
className={`fixed inset-x-3 bottom-3 z-50 mx-auto max-w-md rounded-lg px-4 py-3 text-sm shadow-lg ${
toast.kind === "ok" ? "bg-emerald-600 text-white" : "bg-red-600 text-white"
}`}
>
{toast.text}
</div>
) : null}
</main>
);
}

View file

@ -11,8 +11,9 @@
* The dialog is intentionally chrome-minimal so the PDF
* takes most of the viewport. The footer has:
* - Close
* - Print (calls iframe.contentWindow.print() so the
* operator prints just the PDF, not the whole page)
* - Print (opens the PDF in a new tab — see the note on
* `handlePrint` for why the iframe's own print() gives blank
* pages)
* - Download (uses the `download` attribute so the
* browser saves the file rather than opening it in a
* viewer)
@ -67,28 +68,77 @@ export function PdfPreviewDialog({
subtitle,
downloadFilename,
}: PdfPreviewDialogProps) {
// Ref on the iframe so Print can call the iframe's own
// print() — this prints just the PDF, not the whole page
// (the parent page has the modal chrome which would also
// print otherwise).
// Ref on the iframe so we can tell whether the PDF has finished
// loading before offering a print action.
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [blocked, setBlocked] = useState(false);
// Clear the pop-up-blocked notice on close rather than in an effect:
// syncing state from an effect triggers a second render pass, and
// the flag is only ever set by a click inside this component.
const handleOpenChange = (next: boolean) => {
if (!next) setBlocked(false);
onOpenChange(next);
};
const handlePrint = () => {
// Calling the iframe's contentWindow.print() prints the
// PDF document inside the iframe. Works in Chrome, Edge,
// Firefox, Safari. If the iframe didn't load (network
// blip, broken PDF), the call is a no-op — the operator
// can still use the Download button.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Ignore — some browsers throw on cross-origin or
// blocked-iframe cases. The Download button remains.
// Deliberately opens the PDF in a new tab rather than calling
// `iframe.contentWindow.print()`.
//
// That older approach does NOT work for a PDF in Chrome. Chrome
// renders PDFs with its internal `pdf_embedder` extension, not as
// a real DOM document, so the iframe's `contentDocument` is an
// empty shell:
//
// <html><head><link rel="stylesheet"
// href="chrome-extension://…/pdf_embedder.css"></head>
// <body></body></html>
//
// The PDF *paints* (you can see it), but `print()` runs against
// that empty body — so the operator gets a print dialog full of
// blank pages and no error to explain why. Verified in Chrome by
// reading the iframe's document straight out of the page.
//
// A new tab hands the operator Chrome's real PDF viewer, whose own
// print button works. Same document, no blank pages.
const url = pdfUrl;
const win = window.open(url, "_blank", "noopener,noreferrer");
if (!win) {
// Popup blocked. Say so rather than appearing to do nothing.
setBlocked(true);
}
};
if (blocked) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Pop-up blocked</DialogTitle>
<DialogDescription>
Your browser blocked the new tab needed to print {title}. Allow
pop-ups for this site, or use Download to save the PDF and open it
directly.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
Close
</Button>
<a href={pdfUrl} download={downloadFilename}>
<Button type="button" variant="outline">
<Download className="mr-1 h-3.5 w-3.5" />
Download instead
</Button>
</a>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
// Wide + tall — the PDF is the focus. Min-height is
// important so the iframe can render at a useful size
@ -109,7 +159,7 @@ export function PdfPreviewDialog({
<Button
size="icon"
variant="ghost"
onClick={() => onOpenChange(false)}
onClick={() => handleOpenChange(false)}
aria-label="Close preview"
data-testid="pdf-preview-close"
>
@ -137,14 +187,15 @@ export function PdfPreviewDialog({
<DialogFooter className="px-4 py-3 border-t gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
onClick={() => handleOpenChange(false)}
data-testid="pdf-preview-close-footer"
>
Close
</Button>
{/* Print — calls the iframe's own print() so the
operator prints just the PDF, not the page
chrome behind the modal. */}
{/* Print — opens the PDF in a new tab so the operator gets
Chrome's real PDF viewer, whose print button works. See
the note on `handlePrint` for why the iframe's own
`print()` produces blank pages. */}
<Button
type="button"
variant="outline"
@ -466,16 +517,10 @@ export function QuotePdfPreviewDialog({
type="button"
variant="outline"
onClick={() => {
// Same approach as `PdfPreviewDialog`: print
// the PDF inside the iframe so the operator
// prints just the PDF, not the page chrome.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Ignore — some browsers throw on cross-
// origin / blocked-iframe cases. The
// Download button remains.
}
// Opens in a new tab, same as `handlePrint` above —
// `contentWindow.print()` on a PDF iframe prints
// Chrome's empty pdf_embedder shell, i.e. blank pages.
if (pdfUrl) window.open(pdfUrl, "_blank", "noopener,noreferrer");
}}
data-testid="quote-pdf-preview-print"
>

View file

@ -11,8 +11,12 @@ import { X, Printer, Download, Loader2, ExternalLink } from "lucide-react";
* - On mount, fetches the PDF binary from the API as a Blob, creates
* a local blob URL, and renders it inside an `<iframe>` (browser
* native PDF viewer).
* - Print button: asks the iframe to call its own `window.print()` —
* prints the PDF directly, not the surrounding page chrome.
* - Print button: opens the PDF in a new tab. NOT
* `iframe.contentWindow.print()` — Chrome renders PDFs with its
* internal `pdf_embedder` extension, so the iframe's
* `contentDocument` is an empty shell and `print()` against it
* yields blank pages. Same bug and same fix as
* `components/delivery-notes/preview-dialog.tsx`.
* - Download button: fetches again with `?download=1` to get a
* `Content-Disposition: attachment` response, triggers a save dialog.
* - Open in new tab: opens the blob URL in a new browser tab for
@ -99,13 +103,14 @@ export function QuotePdfViewer({
if (!open) return null;
const handlePrint = () => {
// Print from inside the iframe so only the PDF prints, not the modal chrome.
try {
iframeRef.current?.contentWindow?.print();
} catch {
// Some browsers block cross-frame print; fall back to opening the blob URL.
if (blobUrl) window.open(blobUrl, "_blank");
}
// Open the blob URL in a new tab. Calling
// `iframeRef.current?.contentWindow?.print()` here looks like the
// obvious thing to do and is what this used to do, but Chrome
// backs a PDF iframe with its `pdf_embedder` extension, so the
// frame's document is an empty `<body>` — `print()` on it produces
// a dialog full of blank pages. The new tab gets the real PDF
// viewer, whose own print button works.
if (blobUrl) window.open(blobUrl, "_blank", "noopener,noreferrer");
};
const handleDownload = async () => {

View file

@ -23,6 +23,7 @@ import {
Moon,
Package,
Plus,
QrCode,
Settings as SettingsIcon,
ShoppingCart,
Sun,
@ -119,6 +120,10 @@ const operationsItems: NavEntry[] = [
// reference number. n8n will eventually auto-complete
// these (docs/deliveries-n8n-workflow.md).
{ href: "/deliveries", label: "Deliveries", icon: Truck },
// Counter-side scanner for the printed delivery-note QR. Separate
// standalone PWA at /scan (it declares its own manifest), linked
// here so the office can find it from the CRM too.
{ href: "/scan", label: "DN Scanner", icon: QrCode },
];
// Stock is its own top-level group (not nested under Operations) so the

View file

@ -147,11 +147,15 @@ export function defaultAccentFor(code: LetterheadCode): string {
* return it as a `data:image/svg+xml;base64,...` URI suitable for
* `<Image src={...}>` in @react-pdf.
*
* We embed SVG instead of the original raster (PNG/JPEG) logos because
* @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug that
* replaces every byte ≥ 0x80 in raster image data with `0xFD`. SVG is
* text-based, so it sidesteps that code path — @react-pdf parses the
* SVG XML and embeds vector primitives directly into the PDF stream.
* The logo is embedded as SVG because `public/letterhead/<code>.svg`
* IS an SVG source file — @react-pdf parses the XML and embeds vector
* primitives. (An earlier version of this comment blamed a
* "@react-pdf byte-corruption bug that replaces every byte ≥ 0x80 with
* 0xFD" and used SVG as a workaround. The corruption was real but the
* culprit was this app's own render pipeline — `pdf().toString()`
* re-encodes the PDF as latin1, so U+FFFD replacement characters
* truncated to 0xFD. Fixed by using `renderToBuffer()`; see the note
* at the top of `lib/pdf/render.tsx`. Raster logos work again.)
*
* **Base64, not URL-encoded.** The bundled PDFKit only matches
* `^data:.+?;base64,(.*)$` when decoding inline data URIs (see

View file

@ -1,8 +1,9 @@
import { Document, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer";
import type { Letterhead } from "@/lib/letterhead";
import { defaultAccentFor } from "@/lib/letterhead";
import { formatDateLong } from "./format";
import type { QrMatrix } from "./qr";
export type DeliveryNoteDocumentHeader = {
/** Server-generated delivery note number, e.g. "DN26071401". */
@ -26,14 +27,52 @@ export type DeliveryNoteDocumentProps = {
lines: DeliveryNoteDocumentLine[];
letterhead: Letterhead;
/**
* Logo as a `data:image/svg+xml;charset=utf-8,...` URI. Same SVG
* workaround as the quote + PO documents — @react-pdf v4.5.1
* corrupts every byte ≥ 0x80 in raster image data, but SVG is
* text and goes through the SVG renderer instead.
* Logo as a `data:image/svg+xml;base64,...` URI.
*
* The SVG here is because `public/letterhead/*.svg` IS an SVG source
* file (see `loadLetterheadLogoDataUri`), not because raster data
* needs wrapping. An earlier version of this comment claimed
* "@react-pdf v4.5.1 corrupts every byte >= 0x80 in raster image
* data" and recommended wrapping rasters in `<svg><image>`. That
* claim was wrong, and the workaround is actively harmful: a PNG
* wrapped in `<svg><image>` renders as an EMPTY box with no error,
* while the same PNG passed directly draws correctly. See the note
* at the top of `render.tsx` for what was actually going on.
*/
logoDataUri: string | null;
/**
* Captured signature as a `data:image/png;base64,...` URI, drawn
* into the "Received By" block. Null/absent leaves the blank rule —
* so every existing caller is unaffected.
*
* PNG, passed directly. Verified by rendering all three encodings
* through this exact document: a base64 PNG data URI draws
* correctly, a pure-vector SVG draws correctly, and a PNG *wrapped
* inside* `<svg><image>` renders as an empty box with no error.
*/
signatureImage?: string | null;
/**
* QR code drawn as vector modules, bottom-right of the page.
* Null/absent omits it entirely.
*
* Carries the delivery note number so a scanner app can resolve the
* note. See `src/lib/pdf/qr.ts` for the security note — the value
* is a sequential, enumerable number and this is only appropriate
* for a closed internal consumer.
*/
qr?: QrMatrix | null;
};
/**
* Physical size of the printed QR, in points.
*
* 28pt across a 21-module code plus the 4-module quiet zone is ~0.96pt
* (~0.34mm) per module including margin. Consumer scanners want
* ~0.5mm per module, so this is comfortable without crowding the
* signature block.
*/
const QR_SIZE_PT = 28;
/**
* Renders a printable delivery note PDF. Same visual language as the
* quote + PO documents (letterhead top-left, logo top-right, big
@ -49,6 +88,8 @@ export function DeliveryNoteDocument({
lines,
letterhead,
logoDataUri,
signatureImage = null,
qr = null,
}: DeliveryNoteDocumentProps) {
const accent =
letterhead.accent_color ||
@ -171,7 +212,18 @@ export function DeliveryNoteDocument({
<View style={styles.signatureRow}>
<View style={styles.signatureCol}>
<Text style={styles.signatureTitle}>Received By</Text>
<View style={styles.signatureLine} />
{/* Drawn signature, when one was captured. Replaces the
blank rule rather than sitting on it — a blank line
under a signature reads as "unsigned" at a glance,
which is the opposite of what this block is for. */}
{signatureImage ? (
<>
<Image style={styles.signatureImage} src={signatureImage} />
<View style={styles.signatureRule} />
</>
) : (
<View style={styles.signatureLine} />
)}
<Text style={styles.signatureLabel}>Name & signature</Text>
<View style={styles.signatureLineShort} />
<Text style={styles.signatureLabel}>Date</Text>
@ -192,6 +244,42 @@ export function DeliveryNoteDocument({
{letterhead.footer_text ? (
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
) : null}
{/* QR, bottom-right. Vector modules, not an image — see
`src/lib/pdf/qr.ts` for why that matters. The quiet zone is
baked into the Svg viewBox so scanners see the light margin
the spec requires even though the surrounding page has the
accent-coloured table nearby. */}
{qr ? (
<View style={styles.qrBlock}>
<Svg
width={QR_SIZE_PT}
height={QR_SIZE_PT}
viewBox={`0 0 ${qr.size + qr.quietZone * 2} ${qr.size + qr.quietZone * 2}`}
>
<Rect
x={0}
y={0}
width={qr.size + qr.quietZone * 2}
height={qr.size + qr.quietZone * 2}
fill="#ffffff"
/>
{qr.modules.map((dark, i) =>
dark ? (
<Rect
key={i}
x={(i % qr.size) + qr.quietZone}
y={Math.floor(i / qr.size) + qr.quietZone}
width={1}
height={1}
fill="#000000"
/>
) : null,
)}
</Svg>
<Text style={styles.qrLabel}>Scan for delivery note</Text>
</View>
) : null}
</Page>
</Document>
);
@ -305,6 +393,27 @@ const styles = StyleSheet.create({
height: 22,
marginBottom: 2,
},
/* Signature sits ON the rule rather than above it — the captured
PNG is transparent-background, so the line reads as the baseline
it was drawn against. Fixed height so a short scribble and a
long one both leave the same "Name & signature" position, which
keeps both columns aligned. */
/* The captured PNG is transparent-background, so the rule reads as
the baseline it was drawn against. Height is fixed so a short
scribble and a long one leave "Name & signature" in exactly the
same place — the two columns stay aligned and the page doesn't
reflow when one is signed and the other isn't. */
signatureImage: { width: 130, height: 22 },
/* Zero-height rule for the signed case: the image supplies the 22pt
of vertical space and this draws only the 1pt baseline directly
under it. A full-height `signatureLine` here would leave the
signature floating 22pt above the line it belongs on. */
signatureRule: {
borderBottomWidth: 1,
borderBottomColor: "#222",
height: 0,
marginBottom: 2,
},
signatureLineShort: {
borderBottomWidth: 1,
borderBottomColor: "#222",
@ -313,6 +422,23 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 },
/* QR sits absolute in the bottom-right of the page body, above the
absolutely-positioned footer text. 28pt on a 21-module code is
~0.96pt per module plus the 4-module quiet zone — comfortably
above the ~0.5mm/module that consumer scanners need, while
staying small enough not to crowd the signature block. */
qrBlock: {
position: "absolute",
right: 40,
bottom: 40,
alignItems: "center",
},
qrLabel: {
fontSize: 5.5,
color: "#94a3b8",
marginTop: 2,
letterSpacing: 0.3,
},
footerText: {
fontSize: 8,
color: "#94a3b8",

View file

@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import jsQR from "jsqr";
import zlib from "node:zlib";
import { buildQrMatrix } from "./qr";
import { renderDeliveryNotePdf } from "./render";
import { DeliveryNoteDocument } from "./delivery-note-document";
import type { Letterhead } from "@/lib/letterhead";
/**
* The delivery-note QR is only useful if a phone can actually read it
* off a printed page, so this decodes the matrix back to a string
* rather than eyeballing a screenshot.
*
* Two things are proven separately:
* 1. the MATRIX is a valid, decodable QR — round-tripped through a
* real decoder (jsQR) at several rendered scales, including the
* physical size the document actually prints at;
* 2. the DOCUMENT actually ships that matrix — asserted against the
* PDF's own content stream.
*
* Deliberately no poppler/pdftoppm dependency: rasterising a PDF needs
* a binary that CI may not have, and a test that only runs on your
* machine is a test that quietly stops running.
*/
const letterhead = {
id: "lh1",
collectionId: "pbc_lh",
collectionName: "letterhead_settings",
created: "2026-06-22T20:31:27.165Z",
updated: "2026-06-22T20:31:27.165Z",
display_name: "WESTSCOTT GROUP OF COMPANIES",
address: "47 Station Street",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
vat_percent: 15,
registration_number: "2014/004024/07",
bank_name: "Standard Bank",
bank_account_number: "022196552",
bank_branch_code: "016141",
terms: "",
default_markup_percent: 30,
footer_text: "Thank you for your business.",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#f5b301",
} as unknown as Letterhead;
/** Rasterise a QR matrix to RGBA pixels, mirroring the document's
* white-background / black-module rendering. */
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number) {
const span = qr.size + qr.quietZone * 2;
const dim = span * modulePx;
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
for (let i = 0; i < qr.modules.length; i++) {
if (!qr.modules[i]) continue;
const mx = (i % qr.size) + qr.quietZone;
const my = Math.floor(i / qr.size) + qr.quietZone;
for (let dy = 0; dy < modulePx; dy++) {
for (let dx = 0; dx < modulePx; dx++) {
const px = mx * modulePx + dx;
const py = my * modulePx + dy;
const o = (py * dim + px) * 4;
data[o] = 0;
data[o + 1] = 0;
data[o + 2] = 0;
data[o + 3] = 255;
}
}
}
return { data, width: dim, height: dim };
}
describe("delivery-note QR matrix", () => {
it("round-trips a delivery note number through a real decoder", () => {
const number = "DN26100401";
const qr = buildQrMatrix(number);
// 4 px per module is a generous stand-in for a 200 dpi scan.
const img = rasterise(qr, 4);
const decoded = jsQR(img.data, img.width, img.height);
expect(decoded).not.toBeNull();
expect(decoded?.data).toBe(number);
}, 30_000);
it("still decodes at the module size the document actually prints at", () => {
// 28pt across 29 module-spans (21 + 2x4 quiet) at 200 dpi:
// 28 / 72 * 200 = 77.8 px -> 77.8 / 29 = 2.68 px per module.
// So 2 px/module is the pessimistic end of the real print size, and
// it must decode. 1 px/module is NOT asserted: at 29x29 px total the
// symbol is below what any decoder can resolve, and pretending
// otherwise would be asserting something untrue about the format.
const qr = buildQrMatrix("DN26100401");
for (const px of [2, 3, 4, 6]) {
const img = rasterise(qr, px);
const decoded = jsQR(img.data, img.width, img.height);
expect(
decoded?.data,
`failed to decode at ${px}px per module`,
).toBe("DN26100401");
}
}, 30_000);
it("round-trips the longest plausible delivery note number", () => {
// DN + YY + MM + DD + NN, with NN able to reach 3 digits on a busy
// day. Encoding the realistic maximum proves we don't sit on a
// version boundary that needs a bigger symbol than we assume.
const qr = buildQrMatrix("DN261004999");
const img = rasterise(qr, 4);
expect(jsQR(img.data, img.width, img.height)?.data).toBe("DN261004999");
}, 30_000);
it("refuses an empty string rather than emitting an unscannable symbol", () => {
expect(() => buildQrMatrix(" ")).toThrow();
});
it("keeps the 4-module quiet zone the spec requires", () => {
expect(buildQrMatrix("DN26100401").quietZone).toBe(4);
});
});
describe("delivery-note PDF carries the QR", () => {
const baseProps = {
header: {
deliveryNoteNumber: "DN26100401",
dateIso: "2026-10-04",
orderNumber: "",
companyName: "CNL Industrial Mining Supplies",
customerName: "David Els",
},
lines: [
{ id: "l1", description: "M12 X 50 BOLT BLK", qty: 4 },
{ id: "l2", description: "M16 X 55 BOLT BLK", qty: 2 },
],
letterhead,
logoDataUri: null,
};
/**
* Count SVG fill operations in the PDF's content streams.
*
* `re` (PDF rectangle) is the wrong operator to count: react-pdf
* renders `<Svg>` children as SVG paths and emits an `f` (fill) per
* shape, not a `re`. Verified by inflating the stream — 234 dark
* modules land as 237 `f` ops (the extra three are the white quiet
* zone plate plus two table row backgrounds).
*/
function countSvgFills(buf: Buffer): number {
const s = buf.toString("latin1");
let total = 0;
let idx = 0;
for (;;) {
const i = s.indexOf("/FlateDecode", idx);
if (i === -1) break;
const dictEnd = s.indexOf(">>", i) + 2;
const dict = s.slice(Math.max(0, i - 300), dictEnd);
const lenMatch = /\/Length (\d+)/.exec(dict);
let st = dictEnd;
while (s[st] === "\n" || s[st] === "\r" || s[st] === " ") st++;
if (s.slice(st, st + 6) === "stream") st += 6;
while (s[st] === "\n" || s[st] === "\r") st++;
const length = lenMatch ? Number(lenMatch[1]) : 200;
try {
const text = zlib.inflateSync(buf.subarray(st, st + length)).toString("latin1");
total += (text.match(/\bf\b/g) || []).length;
} catch {
/* not a content stream */
}
idx = st + length;
}
return total;
}
it("emits a vector fill for every dark module", async () => {
const qr = buildQrMatrix("DN26100401");
const buf = await renderDeliveryNotePdf({ ...baseProps });
// Without the QR the document has only a handful of fills (table
// row backgrounds, the white quiet-zone plate). Asserting the count
// is at least the dark-module count therefore genuinely fails if the
// QR is dropped — which a "the PDF is non-empty" check would not.
const darkModules = qr.modules.filter(Boolean).length;
expect(countSvgFills(buf)).toBeGreaterThanOrEqual(darkModules);
}, 30_000);
it("renders the document fine with no QR at all", async () => {
// Note this asserts on the DOCUMENT, not on
// `renderDeliveryNotePdf` — the render layer always builds a QR and
// passes it in as `qr={qr}` AFTER `{...props}`, so a caller's
// `qr: null` is deliberately overridden. (An earlier version of
// this test called the renderer and therefore silently tested the
// QR-present path while claiming to test the absent one.) The
// document tolerating a null QR is the thing that matters: it is
// what makes the renderer's `try/catch` a safe degradation rather
// than a promise it can't keep.
const buf = await renderToBuffer(<DeliveryNoteDocument {...baseProps} />);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
// And it really is the smaller document.
const withQr = await renderToBuffer(
<DeliveryNoteDocument {...baseProps} qr={buildQrMatrix("DN26100401")} />,
);
expect(withQr.length).toBeGreaterThan(buf.length);
}, 30_000);
});

77
src/lib/pdf/qr.ts Normal file
View file

@ -0,0 +1,77 @@
import QRCode from "qrcode";
/**
* QR matrix builder for documents printed by `@react-pdf/renderer`.
*
* ## Why the matrix, and not a PNG
*
* A QR could be embedded as an image, but a matrix renders as pure
* VECTOR — one `<Rect>` per dark module — which is strictly better
* here:
*
* - it stays crisp at any print size or DPI, with no resampling
* - it adds ~30 shapes rather than a compressed image blob, so the
* PDF stays small
* - it does not depend on react-pdf's raster image path at all.
* That path was exactly what the old `pdf().toString()` pipeline
* silently corrupted (see the note at the top of `render.tsx`),
* and vector content is immune to it.
*
* ## What the QR carries
*
* Currently just the delivery note number, e.g. `DN26100401`.
*
* SECURITY NOTE — this is a deliberate product decision, not an
* oversight. The number is a daily sequential counter (`DN{YY}{MM}{DD}{NN}`
* starting at 01), so it is fully enumerable: anyone can guess every
* delivery note that will ever exist. A QR carrying the bare number,
* pointed at an upload endpoint, is therefore a signature-forgery
* vector.
*
* That is acceptable *today* because the intended consumer is a
* closed, logged-in internal app on the office network, not the public
* internet. If this QR is ever exposed to customers, or the scan
* target is ever made reachable without a login, the fix is to encode
* an unguessable per-note token instead — e.g.
* `DN26100401.<base64url(HMAC-SHA256(noteId, SIGNING_SECRET))[0..22]>`,
* which needs no new column because it is recomputed rather than
* stored. Raise it before the surface changes, not after.
*/
export type QrMatrix = {
/** Modules per side, excluding the quiet zone. A version-1 code is 21. */
size: number;
/** Row-major, length `size * size`. `true` = dark module. */
modules: boolean[];
/**
* QR spec requires 4 modules of light margin on every side, and
* scanners rely on it. Without it a code sitting against other ink
* on the page fails to decode, so the padding is part of the
* contract, not decoration.
*/
quietZone: number;
};
/**
* Encode `text` as a QR matrix.
*
* Error correction `M` (~15%) is the deliberate middle: enough to keep
* the code readable through a thermal-grade print, a fingerprint smear
* and a bent-in-a-pocket scan, without inflating the module count (and
* so the physical size) the way `H` would for a payload this short.
*/
export function buildQrMatrix(text: string): QrMatrix {
const trimmed = text.trim();
if (!trimmed) {
throw new Error("buildQrMatrix: refusing to encode an empty string");
}
const qr = QRCode.create(trimmed, { errorCorrectionLevel: "M" });
const size = qr.modules.size;
// `qr.modules.data` is a FLAT row-major Uint8Array of size*size.
// (It is not an array of rows — `data[r]` is a single module, which
// is a trap worth naming.)
const modules: boolean[] = new Array(size * size);
for (let i = 0; i < modules.length; i++) {
modules[i] = qr.modules.data[i] === 1;
}
return { size, modules, quietZone: 4 };
}

View file

@ -62,20 +62,22 @@ export interface QuoteDocumentProps {
items: QuoteItem[];
customer: Customer;
letterhead: Letterhead;
/** Logo as a `data:image/svg+xml;charset=utf-8,...` URI. We use SVG
* instead of the original raster (PNG/JPEG) logos because
* @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug
* that replaces every byte ≥ 0x80 in raster image data with `0xFD`,
* turning valid JPEGs (`0xFF 0xD8` SOI) into garbage. SVG is text-
* based, so it sidesteps the bug entirely — the data URI is a plain
* UTF-8 string and goes through @react-pdf's SVG renderer (which
* parses XML and embeds vector paths), not the broken raster path.
/** Logo as a `data:image/svg+xml;base64,...` URI.
*
* If we ever need a non-SVG logo again, the upstream fixes are:
* - Upgrade @react-pdf past the byte-corruption regression
* - Post-process with PDFKit's `doc.image()` (bypasses the bug)
* - Rasterize the SVG→PNG with a server-side library and embed
* the PNG via PDFKit the same way
* SVG because `public/letterhead/*.svg` IS an SVG source file —
* @react-pdf parses the XML and embeds vector primitives. (This
* comment previously described a "@react-pdf v4.5.1 byte-corruption
* bug that replaces every byte >= 0x80 with 0xFD, turning JPEG SOI
* markers into garbage", and listed workarounds. The corruption was
* real; the cause was this app's render pipeline, not @react-pdf —
* `pdf().toString()` re-encoded the PDF as latin1, so U+FFFD
* replacement chars truncated to 0xFD. Fixed by `renderToBuffer()`;
* see the note at the top of `lib/pdf/render.tsx`. Raster logos
* work again, and none of those workarounds are needed.)
*
* Base64, not `;charset=utf-8,<urlencoded>`: the bundled PDFKit
* only matches `^data:.+?;base64,(.*)$` for inline data URIs and
* silently drops anything else as a filesystem path.
*/
logoDataUri: string | null;
/**

View file

@ -0,0 +1,283 @@
import { describe, it, expect } from "vitest";
import zlib from "node:zlib";
import { renderDeliveryNotePdf } from "./render";
import type { Letterhead } from "@/lib/letterhead";
/**
* Regression guard for the PDF byte pipeline.
*
* ## The bug this exists to stop coming back
*
* Every renderer used to do:
*
* const instance = pdf(<Doc {...props} />);
* const pdfString = await instance.toString();
* return Buffer.from(pdfString, "binary");
*
* The stated reason was that `toBuffer()` broke under jsdom. That was
* true of `pdf(el).toBuffer()` — which is a misnamed STREAM getter — but
* the fix chosen for it, `toString()`, was far worse than the problem:
*
* - `toString()` renders with `compress: false`, so every PDF this
* app produced was uncompressed. react-pdf's own source says why:
* "when rendering to string if compress=true the document is
* blank". The method is flagged upstream as buggy (#2112, #2095).
* - Round-tripping the PDF through a JS string and re-encoding as
* latin1 mangles binary streams. Text is ASCII in the content
* stream so it survives; a raster image is a compressed binary
* blob that does not. The image object ends up embedded but is
* never painted.
*
* It stayed latent because the only image in these documents is the
* letterhead logo, and that logo is an SVG — SVG is text, so it
* round-tripped perfectly and hid the whole thing. A signature is the
* first genuinely raster asset this pipeline has to carry.
*
* You cannot "try toBuffer, fall back to toString" to fix this:
* calling `toBuffer()` first consumes the render, and a subsequent
* `toString()` on the same instance returns a document with the
* content missing. One `pdf()` instance, one terminal call. The fix is
* `renderToBuffer()`, which does the right thing in a single pass and
* works under jsdom too.
*
* ## What these tests can and cannot prove
*
* The assertions below are deliberately things that are true in BOTH
* Node and jsdom, because the vitest environment takes a different
* react-pdf image path: under jsdom the embedded image's zlib stream
* will not inflate, even though the exact same document renders a
* perfect signature under plain Node (verified by rasterising the
* output with `pdftoppm` and measuring the pixels). So we do NOT assert
* that image streams inflate — that would be a test that passes in
* production and fails in CI, or worse, one tuned until it is green
* and proves nothing.
*
* Instead we pin the three environment-independent facts that changed:
* the output is compressed, the content stream is valid, and an image
* appears when (and only when) a signature is supplied.
*/
const letterhead = {
id: "lh1",
collectionId: "pbc_lh",
collectionName: "letterhead_settings",
created: "2026-06-22T20:31:27.165Z",
updated: "2026-06-22T20:31:27.165Z",
display_name: "CNL Mining Supplies",
address: "47 Station Street",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
vat_percent: 15,
registration_number: "2014/004024/07",
bank_name: "Standard Bank",
bank_account_number: "022196552",
bank_branch_code: "016141",
terms: "",
default_markup_percent: 30,
footer_text: "",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#738224",
} as unknown as Letterhead;
/* ---------- a tiny PNG encoder, so the test owns its own fixture ---------- */
function crc32(buf: Buffer): number {
const table: number[] = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(typed));
return Buffer.concat([len, typed, crc]);
}
const SIG_W = 120;
const SIG_H = 30;
/** 120x30 RGBA PNG — a black wave on a transparent background. */
function makeSignaturePng(): Buffer {
const raw = Buffer.alloc(SIG_H * (1 + SIG_W * 4));
for (let y = 0; y < SIG_H; y++) {
const rowStart = y * (1 + SIG_W * 4);
raw[rowStart] = 0; // per-scanline filter: none
for (let x = 0; x < SIG_W; x++) {
const wave = 15 + Math.round(Math.sin((x / (SIG_W - 1)) * Math.PI * 4) * 8);
const onStroke = Math.abs(y - wave) <= 2;
const p = rowStart + 1 + x * 4;
raw[p] = 0;
raw[p + 1] = 0;
raw[p + 2] = 0;
raw[p + 3] = onStroke ? 255 : 0;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIG_W, 0);
ihdr.writeUInt32BE(SIG_H, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", zlib.deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
/**
* Walk every FlateDecode stream in the PDF and report whether it
* inflates. Returns one entry per stream, tagged image vs content.
*
* Hand-rolled rather than pulling in a PDF library: the whole point is
* to answer "are these bytes intact", and a parser that can say "this
* one will not inflate" is exactly the signal being asserted.
*/
function scanFlateStreams(
buf: Buffer,
): Array<{ kind: "image" | "content"; inflates: boolean }> {
const s = buf.toString("latin1");
const out: Array<{ kind: "image" | "content"; inflates: boolean }> = [];
let idx = 0;
for (;;) {
const i = s.indexOf("/FlateDecode", idx);
if (i === -1) break;
const dictEnd = s.indexOf(">>", i) + 2;
const dict = s.slice(Math.max(0, i - 300), dictEnd);
const kind: "image" | "content" = /\/Subtype\s*\/Image/.test(dict) ? "image" : "content";
const lenMatch = /\/Length (\d+)/.exec(dict);
let st = dictEnd;
while (s[st] === "\n" || s[st] === "\r" || s[st] === " ") st++;
if (s.slice(st, st + 6) === "stream") st += 6;
while (s[st] === "\n" || s[st] === "\r") st++;
const length = lenMatch ? Number(lenMatch[1]) : 200;
const raw = buf.subarray(st, st + length);
let inflates = true;
try {
zlib.inflateSync(raw);
} catch {
inflates = false;
}
out.push({ kind, inflates });
idx = st + length;
}
return out;
}
/** Width/Height of every image XObject in the PDF. */
function imageGeometry(buf: Buffer): Array<{ width: number; height: number }> {
const s = buf.toString("latin1");
const out: Array<{ width: number; height: number }> = [];
let idx = 0;
for (;;) {
const i = s.indexOf("/Subtype /Image", idx);
if (i === -1) break;
const dict = s.slice(i, s.indexOf(">>", i) + 2);
const w = /\/Width (\d+)/.exec(dict);
const h = /\/Height (\d+)/.exec(dict);
idx = i + 1;
if (w && h) out.push({ width: Number(w[1]), height: Number(h[1]) });
}
return out;
}
/**
* Size of the same document through the old `pdf().toString()` path,
* measured when this test was written. Used as the ceiling in the
* compression assertion.
*/
const OLD_UNCOMPRESSED_BYTES = 12710;
const baseProps = {
header: {
deliveryNoteNumber: "DN26100301",
dateIso: "2026-10-03",
orderNumber: "",
companyName: "Test Mining",
customerName: "Ysterbaard",
},
lines: [
{ id: "l1", description: "16mm Hex Bolt", qty: 10 },
{ id: "l2", description: "16mm Hex Nut", qty: 10 },
],
letterhead,
logoDataUri: null,
};
describe("PDF byte pipeline", () => {
it("returns a real Node Buffer, not a string round-trip", async () => {
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("compresses its streams — the old toString() path produced none", async () => {
// THE regression assertion. `pdf().toString()` renders with
// compress:false, so the old output had zero /FlateDecode streams
// and was ~4x larger. Measured on this document before it grew a
// QR code:
// old (toString) 12710 bytes, 0 Flate streams
// new (renderToBuffer) 3149 bytes, compressed
// The QR added ~2.5 KB of vector content since, so the ceiling is
// expressed against the OLD baseline rather than a round number
// picked when the document was smaller — an absolute byte count
// is a tripwire that fires every time a feature is added, which
// is how a real regression gets tuned away.
const buf = await renderDeliveryNotePdf({ ...baseProps });
const streams = scanFlateStreams(buf);
expect(streams.length).toBeGreaterThan(0);
expect(buf.toString("latin1")).toContain("/FlateDecode");
expect(buf.length).toBeLessThan(OLD_UNCOMPRESSED_BYTES);
}, 30_000);
it("produces a content stream that inflates cleanly", async () => {
// The latin1 round-trip also mangled binary streams — poppler
// reported "Bad FCHECK in flate stream" on the old output. The
// content stream is the one that is environment-independent, so
// this is the check that the bytes actually arrive undamaged.
const buf = await renderDeliveryNotePdf({ ...baseProps });
const content = scanFlateStreams(buf).filter((st) => st.kind === "content");
expect(content.length).toBeGreaterThan(0);
for (const st of content) {
expect(st.inflates).toBe(true);
}
}, 30_000);
it("embeds a raster image with the right geometry when a signature is supplied", async () => {
const buf = await renderDeliveryNotePdf({
...baseProps,
signatureImage: `data:image/png;base64,${makeSignaturePng().toString("base64")}`,
});
const geometry = imageGeometry(buf);
expect(geometry.length).toBeGreaterThan(0);
for (const g of geometry) {
expect(g.width).toBe(SIG_W);
expect(g.height).toBe(SIG_H);
}
}, 30_000);
it("embeds no image at all when no signature is supplied", async () => {
// The negative control: if this ever fails, something is leaking
// an image into every DN regardless of signature state.
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(imageGeometry(buf)).toHaveLength(0);
}, 30_000);
it("still renders a blank signature line with no signature", async () => {
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
});

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import zlib from "node:zlib";
import {
renderQuotePdf,
renderDeliveryNotePdf,
@ -17,6 +18,35 @@ import type { StockItem } from "@/lib/server/stock-items-service";
* dep misconfig, etc.) trips the test even without eyeballing output.
*/
/**
* Concatenate the inflated text of every FlateDecode stream in a PDF.
*
* Needed because `renderToBuffer()` compresses: text lives inside
* zlib-compressed content streams, so scanning the raw buffer for a
* string finds nothing. Any assertion of the form "this PDF does /
* does not contain X" has to go through here or it is vacuous.
*/
function decompressPdfText(buf: Buffer): string {
const out: string[] = [];
const marker = Buffer.from("stream");
let idx = buf.indexOf(marker);
while (idx !== -1) {
let start = idx + marker.length;
// Skip the EOL after the `stream` keyword.
if (buf[start] === 0x0d) start += 1;
if (buf[start] === 0x0a) start += 1;
const end = buf.indexOf(Buffer.from("endstream"), start);
if (end === -1) break;
try {
out.push(zlib.inflateSync(buf.subarray(start, end)).toString("latin1"));
} catch {
// Not every stream is Flate (images may be raw or DCT); skip it.
}
idx = buf.indexOf(marker, end);
}
return out.join("\n");
}
const baseLetterhead: Letterhead = {
id: "lh1",
collectionId: "pbc_1010367695",
@ -301,7 +331,13 @@ describe("renderDeliveryNotePdf", () => {
letterhead: baseLetterhead,
logoDataUri: null,
});
expect(buf.toString("latin1")).not.toContain("Supplier");
// Grep the DECOMPRESSED text, not the raw bytes. The renderers
// used to funnel through `pdf().toString()`, which renders
// uncompressed — so a raw `buf.toString("latin1")` scan really did
// see the text. `renderToBuffer()` compresses, so the same scan
// would match nothing and this assertion would pass for the wrong
// reason: a test that quietly stops testing is worse than no test.
expect(decompressPdfText(buf)).not.toContain("Supplier");
}, 30_000);
it("renders with the ISHERWOOD letterhead code (no accent_color override)", async () => {
const buf = await renderDeliveryNotePdf({

View file

@ -1,4 +1,52 @@
import { pdf } from "@react-pdf/renderer";
/**
* Server-side PDF rendering. Every renderer here goes through
* `renderToBuffer()` from `@react-pdf/renderer`.
*
* ## Why not `pdf(<Doc />).toString()`
*
* Every renderer used to funnel through
*
* const instance = pdf(<Doc {...props} />);
* const pdfString = await instance.toString();
* return Buffer.from(pdfString, "binary");
*
* on the belief that `toBuffer()` was unusable under jsdom (Vitest's
* environment). That belief was half right and led somewhere much
* worse than a test-only problem.
*
* Three things are going on, and only the first was ever real:
*
* 1. `pdf(el).toBuffer()` is NOT the buffer API. It resolves to the
* PDFKit *stream* (react-pdf carries a TODO to rename it
* `toStream`), and under jsdom it resolves to a bare
* `PDFDocument` handle. That is the jsdom breakage the old
* comment described — but it applies to that method, not to the
* module-level `renderToBuffer()` helper, which collects the
* stream into a real Node Buffer and works fine under jsdom.
*
* 2. `toString()` renders with `compress: false`. react-pdf's own
* source says why: "when rendering to string if compress=true
* the document is blank". So every PDF this app produced was
* uncompressed — larger than necessary — and the method is
* flagged upstream as buggy (react-pdf issues #2112, #2095).
*
* 3. The round-trip through a JS string corrupts binary streams.
* A raster image (PNG/JPEG) arrives as a Flate-compressed binary
* blob that does not survive being stringified and re-encoded as
* latin1. The image object ends up embedded in the file but is
* never painted. Text is ASCII in the content stream, so text
* renders fine and the corruption is invisible until something
* raster goes in — which is why this sat latent for so long. The
* letterhead logo hid it completely: that logo is an SVG, and
* SVG is text, so it round-tripped perfectly.
*
* `renderToBuffer()` fixes all three at once and needs no jsdom
* fallback. `render-image-pipeline.test.ts` is the regression guard —
* it renders a real raster image and asserts the bytes come back
* intact, so this cannot silently come back.
*/
import { renderToBuffer } from "@react-pdf/renderer";
import { buildQrMatrix, type QrMatrix } from "./qr";
import { QuoteDocument, type QuoteDocumentProps } from "./quote-document";
import {
PurchaseOrderDocument,
@ -34,23 +82,17 @@ import {
/**
* Server-side render of a `<QuoteDocument>` to a PDF Buffer.
*
* Uses `@react-pdf/renderer`'s Node API (`pdf(<JSX />).toBuffer()`) which
* runs entirely in-process — no headless Chromium, no Playwright/Puppeteer,
* no browser binary in the Docker image.
* Runs entirely in-process via `renderToBuffer()` — no headless
* Chromium, no Playwright/Puppeteer, no browser binary in the Docker
* image. See the note at the top of this file for why that specific
* API and not `pdf(<JSX />).toString()`.
*
* Always returns a fresh Buffer; never cached. Reads of letterhead,
* quote, items, and customer happen in the API route before this is
* called, so any edit in PB propagates on the next generation.
*/
export async function renderQuotePdf(props: QuoteDocumentProps): Promise<Buffer> {
const instance = pdf(<QuoteDocument {...props} />);
// `toBuffer()` works in pure Node, but under jsdom (Vitest's default
// test environment) it returns a PDFKit document handle instead. We
// funnel through `toString()` which always resolves to a string of
// raw PDF bytes, then convert to a real Node Buffer. The string path
// is ~the same performance as toBuffer in production.
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<QuoteDocument {...props} />);
}
/**
@ -65,16 +107,12 @@ export async function renderQuotePdf(props: QuoteDocumentProps): Promise<Buffer>
* the PO record itself — replaced 2026-07-06 the separate
* `purchase_order_pdfs` collection).
*
* `toString()` is used for the same jsdom-test compat reason as the
* quote renderer — both routes are test-covered and we want them to
* produce the same shape regardless of environment.
* Same `renderToBuffer()` pipeline as every other renderer here.
*/
export async function renderPurchaseOrderPdf(
props: PurchaseOrderDocumentProps
): Promise<Buffer> {
const instance = pdf(<PurchaseOrderDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<PurchaseOrderDocument {...props} />);
}
/**
@ -82,10 +120,8 @@ export async function renderPurchaseOrderPdf(
*
* Stock POs are pure stock-replenishment orders with no customer in
* the loop — the document layout is a slimmed-down version of the
* customer-PO PDF (no Order Ref line, no "For: <customer>"). The
* same `toString()` jsdom-compat pattern is used so the stock-PO
* PDF route's tests run under the same setup as the other PDF
* routes.
* customer-PO PDF (no Order Ref line, no "For: <customer>"). Same
* `renderToBuffer()` pipeline as the rest of this file.
*
* The route drops the resulting Buffer into
* `stock_purchases.documents` (file, multi) + appends a metadata
@ -97,9 +133,7 @@ export async function renderPurchaseOrderPdf(
export async function renderStockPurchasePdf(
props: StockPurchaseDocumentProps
): Promise<Buffer> {
const instance = pdf(<StockPurchaseDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<StockPurchaseDocument {...props} />);
}
/**
@ -115,9 +149,20 @@ export async function renderStockPurchasePdf(
* the same item.
*/
export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): Promise<Buffer> {
const instance = pdf(<DeliveryNoteDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
// Every delivery note carries a QR of its own number so the
// warehouse/driver scanner app can resolve the note straight off
// the printed page. Built here rather than in the document so the
// QR encoder stays out of the presentational component.
//
// Non-fatal: a QR that fails to encode must never cost the operator
// their delivery note. The document renders without it.
let qr: QrMatrix | null = null;
try {
qr = buildQrMatrix(props.header.deliveryNoteNumber);
} catch (err) {
console.error("[renderDeliveryNotePdf] QR build failed:", err);
}
return renderToBuffer(<DeliveryNoteDocument {...props} qr={qr} />);
}
/**
@ -135,25 +180,20 @@ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): P
export async function renderRepairRequestPdf(
props: RepairRequestDocumentProps
): Promise<Buffer> {
const instance = pdf(<RepairRequestDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<RepairRequestDocument {...props} />);
}
/**
* Server-side render of the stock-orders *list* PDF — every
* purchase in one document, not one PDF per PO. Sibling of
* `renderStockPurchasePdf` (which is the single-PO, supplier-facing
* PDF). Both walk `@react-pdf/renderer`'s `pdf(<JSX />).toString()`
* pipeline so the test suite runs under jsdom without dragging in
* a Chromium binary.
* PDF). Both use the same `renderToBuffer()` pipeline, which needs no
* browser binary.
*/
export async function renderStockPurchaseListPdf(
props: StockPurchaseListDocumentProps
): Promise<Buffer> {
const instance = pdf(<StockPurchaseListDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<StockPurchaseListDocument {...props} />);
}
/**
@ -163,9 +203,7 @@ export async function renderStockPurchaseListPdf(
export async function renderRepPerformanceDetailPdf(
props: RepPerformanceDetailDocumentProps
): Promise<Buffer> {
const instance = pdf(<RepPerformanceDetailDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<RepPerformanceDetailDocument {...props} />);
}
/**
@ -175,9 +213,7 @@ export async function renderRepPerformanceDetailPdf(
export async function renderRepPerformanceTeamPdf(
props: RepPerformanceTeamDocumentProps
): Promise<Buffer> {
const instance = pdf(<RepPerformanceTeamDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<RepPerformanceTeamDocument {...props} />);
}
/**
@ -189,7 +225,5 @@ export async function renderRepPerformanceTeamPdf(
export async function renderStockItemStocktakePdf(
props: StockItemStocktakeDocumentProps
): Promise<Buffer> {
const instance = pdf(<StockItemStocktakeDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
return renderToBuffer(<StockItemStocktakeDocument {...props} />);
}

View file

@ -27,10 +27,11 @@ export type RepairRequestDocumentProps = {
contactEmail: string;
letterhead: Letterhead;
/**
* Logo as a `data:image/svg+xml;base64,...` URI. Same SVG workaround
* as the quote / PO / DN documents — @react-pdf v4.5.1 corrupts every
* byte ≥ 0x80 in raster image data, but SVG is text and goes through
* the SVG renderer instead.
* Logo as a `data:image/svg+xml;base64,...` URI. SVG because
* `public/letterhead/*.svg` IS an SVG source file — this is not a
* workaround for a react-pdf raster bug (there wasn't one; see the
* note at the top of `lib/pdf/render.tsx` for what that mistaken
* belief led to).
*/
logoDataUri: string | null;
};

View file

@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { buildQrMatrix } from "@/lib/pdf/qr";
import {
isDeliveryNoteNumber,
parseDeliveryNoteNumber,
decodeQrFromCanvas,
detectScannerSupport,
} from "./qr-detect";
/**
* The scanner is judged on one thing: does it read the QR that
* `buildQrMatrix` actually puts on a delivery note? So the headline
* tests are END-TO-END — encode with the production encoder,
* rasterise, decode through the production fallback path, and assert
* we get the delivery note number back.
*
* `BarcodeDetector` does not exist under jsdom, so these always
* exercise the jsQR fallback. Deliberate: that is the path that has
* to work on the devices the native API abandons (iOS, Firefox), and
* it is the easy one to silently break.
*/
type Raster = { data: Uint8ClampedArray; width: number; height: number };
/** Render a QR matrix to RGBA exactly as a canvas would hold it. */
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number): Raster {
const span = qr.size + qr.quietZone * 2;
const dim = span * modulePx;
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
for (let i = 0; i < qr.modules.length; i++) {
if (!qr.modules[i]) continue;
const mx = (i % qr.size) + qr.quietZone;
const my = Math.floor(i / qr.size) + qr.quietZone;
for (let dy = 0; dy < modulePx; dy++) {
for (let dx = 0; dx < modulePx; dx++) {
const o = ((my * modulePx + dy) * dim + (mx * modulePx + dx)) * 4;
data[o] = 0;
data[o + 1] = 0;
data[o + 2] = 0;
data[o + 3] = 255;
}
}
}
return { data, width: dim, height: dim };
}
function fakeCanvas(image: Raster): HTMLCanvasElement {
return { width: image.width, height: image.height } as unknown as HTMLCanvasElement;
}
function fakeCtx(image: Raster): CanvasRenderingContext2D {
return {
getImageData: () => ({ data: image.data, width: image.width, height: image.height }),
} as unknown as CanvasRenderingContext2D;
}
describe("parseDeliveryNoteNumber", () => {
it("accepts a real delivery note number", () => {
expect(parseDeliveryNoteNumber("DN26100401")).toBe("DN26100401");
});
it("accepts a 3-digit day counter (a busy day)", () => {
expect(parseDeliveryNoteNumber("DN261004123")).toBe("DN261004123");
});
it("normalises case and surrounding whitespace", () => {
expect(parseDeliveryNoteNumber(" dn26100401 \n")).toBe("DN26100401");
});
it("rejects a random QR payload", () => {
// A supplier's packing label or a pallet code must not reach the
// network — these return null and the UI says "not a DN".
expect(parseDeliveryNoteNumber("https://example.com/product/99281")).toBeNull();
expect(parseDeliveryNoteNumber("ORDER-12345")).toBeNull();
expect(parseDeliveryNoteNumber("mailto:accounts@wfsales.co.za")).toBeNull();
expect(parseDeliveryNoteNumber("")).toBeNull();
});
it("rejects a DN-shaped string with a non-numeric body", () => {
expect(parseDeliveryNoteNumber("DNabcdefgh")).toBeNull();
});
it("pulls the number out of a scan URL", () => {
// Lets a printed QR be re-pointed at a URL later without
// reprinting every sheet already in circulation.
expect(parseDeliveryNoteNumber("https://wfdash.example/scan?dn=DN26100401")).toBe(
"DN26100401",
);
expect(parseDeliveryNoteNumber("https://wfdash.example/dn/DN26100401")).toBe(
"DN26100401",
);
});
});
describe("isDeliveryNoteNumber", () => {
it("is a strict shape check", () => {
// DN + YY(2) MM(2) DD(2) NN(2) = 8 digits is the normal shape.
expect(isDeliveryNoteNumber("DN26100401")).toBe(true);
// A busy day pushes the counter to 3 digits (9 total).
expect(isDeliveryNoteNumber("DN261004001")).toBe(true);
// Still accepted with a margin above that.
expect(isDeliveryNoteNumber("DN2610040123")).toBe(true);
// Too short — 7 digits.
expect(isDeliveryNoteNumber("DN2610040")).toBe(false);
// Too long — 11 digits.
expect(isDeliveryNoteNumber("DN26100401234")).toBe(false);
expect(isDeliveryNoteNumber("XX26100401")).toBe(false);
});
});
describe("decodeQrFromCanvas — end to end with the real delivery-note QR", () => {
it("reads back the number the delivery-note PDF prints", async () => {
const qr = buildQrMatrix("DN26100401");
const image = rasterise(qr, 4);
const raw = await decodeQrFromCanvas(
fakeCanvas(image),
fakeCtx(image),
image.width,
image.height,
);
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
}, 30_000);
it("reads a busy-day 3-digit number too", async () => {
const qr = buildQrMatrix("DN261004123");
const image = rasterise(qr, 4);
const raw = await decodeQrFromCanvas(
fakeCanvas(image),
fakeCtx(image),
image.width,
image.height,
);
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN261004123");
}, 30_000);
it("still reads it at the pessimistic end of the printed size", async () => {
// 2px/module ~ the real 200 dpi case. A camera at an angle is
// worse, but if 2px fails on a clean frame the printed QR is too
// small and that is worth catching here.
const qr = buildQrMatrix("DN26100401");
const image = rasterise(qr, 2);
const raw = await decodeQrFromCanvas(
fakeCanvas(image),
fakeCtx(image),
image.width,
image.height,
);
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
}, 30_000);
it("returns null for a frame with no code (must not throw)", async () => {
const blank = new Uint8ClampedArray(120 * 120 * 4).fill(255);
const image: Raster = { data: blank, width: 120, height: 120 };
const raw = await decodeQrFromCanvas(
fakeCanvas(image),
fakeCtx(image),
120,
120,
);
expect(raw).toBeNull();
}, 30_000);
});
describe("detectScannerSupport", () => {
it("reports the jsQR fallback when BarcodeDetector is absent", () => {
// jsdom has no BarcodeDetector — exactly the iOS/Firefox case.
expect(detectScannerSupport().kind).toBe("fallback");
});
it("does not throw when browser globals are missing (SSR/prerender)", () => {
expect(() => detectScannerSupport()).not.toThrow();
});
});

211
src/lib/scan/qr-detect.ts Normal file
View file

@ -0,0 +1,211 @@
import jsQR from "jsqr";
/**
* QR detection for the /scan app.
*
* ## Why there are two detectors
*
* The native `BarcodeDetector` (Shape Detection API) is fast and
* allocation-free, but it is **Chrome-only**. As of 2026 it is not
* Baseline: Safari/iOS still has it "Under Consideration" (WebKit bug
* #254573) and Firefox does not ship it at all. It is also
* secure-context-only, so plain-http office LAN access silently
* disables it.
*
* A scanner that only uses it simply does not work on an iPhone — and
* a warehouse tablet is exactly the sort of device that might be one.
* So: native where it exists, `jsQR` everywhere else. `jsQR` is pure
* JS (no 2 MB WASM payload, which matters for a PWA on a weak signal),
* and the delivery-note QR is always a QR code, so a QR-only decoder
* is sufficient.
*
* Both paths run against the same `<canvas>` frame, so the caller
* never branches.
*/
export type QrDetectorKind = "native" | "fallback" | "none";
/**
* Which detector this browser can actually run, and whether the
* camera can be opened at all.
*
* `secureContext` and `mediaDevices` are reported separately from
* `native` because a http:// origin loses BOTH the native detector
* and — in most browsers — `getUserMedia` itself, and those need
* different messages ("this device can't scan" vs "open it over
* HTTPS").
*/
export function detectScannerSupport(): {
kind: QrDetectorKind;
camera: boolean;
secureContext: boolean;
} {
const secureContext =
typeof window !== "undefined" && window.isSecureContext !== false;
const native =
typeof window !== "undefined" && "BarcodeDetector" in window;
const camera =
typeof navigator !== "undefined" &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === "function";
return {
kind: native ? "native" : "fallback",
camera,
secureContext,
};
}
/**
* Delivery note numbers are `DN{YY}{MM}{DD}{NN}` — DN + 8 digits, with
* the day-counter able to run to 3 digits on a busy day.
*
* Validated BEFORE any network call so a random QR on a packing slip
* (a supplier's, a pallet label's) can't fire a lookup, and so the
* manual-entry box gives immediate feedback instead of a 404.
*/
const DN_PATTERN = /^DN\d{8,10}$/i;
export function isDeliveryNoteNumber(value: string): boolean {
return DN_PATTERN.test(value.trim());
}
/**
* Turn a raw scanned string into a delivery note number, or null.
*
* Tolerates a URL that embeds the number, because a QR may be
* re-pointed at a scan URL later without changing the printed paper —
* `.../scan?dn=DN26100401` still resolves.
*/
export function parseDeliveryNoteNumber(raw: string): string | null {
const text = (raw || "").trim();
if (!text) return null;
if (DN_PATTERN.test(text)) return text.toUpperCase();
// A URL, or a path ending in the number.
const fromUrl = /[?&]dn=([A-Za-z0-9]+)/.exec(text) ?? /\/([A-Za-z0-9]+)\/?$/.exec(text);
const candidate = fromUrl?.[1] ?? "";
return DN_PATTERN.test(candidate) ? candidate.toUpperCase() : null;
}
type NativeDetector = {
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
};
let nativeDetector: NativeDetector | null = null;
let nativeDetectorChecked = false;
async function getNativeDetector(): Promise<NativeDetector | null> {
if (nativeDetectorChecked) return nativeDetector;
nativeDetectorChecked = true;
if (typeof window === "undefined" || !("BarcodeDetector" in window)) {
return null;
}
try {
const Ctor = (window as unknown as {
BarcodeDetector: {
new (o: { formats: string[] }): NativeDetector;
getSupportedFormats?: () => Promise<string[]>;
};
}).BarcodeDetector;
const supported = await Ctor.getSupportedFormats?.();
// Some builds expose the constructor but not QR in practice.
// If we can query and QR is missing, don't use it.
if (Array.isArray(supported) && !supported.includes("qr_code")) {
return null;
}
nativeDetector = new Ctor({ formats: ["qr_code"] });
} catch {
nativeDetector = null;
}
return nativeDetector;
}
/**
* Decode one already-drawn canvas frame. Returns the raw QR text, or
* null when the frame holds no readable code.
*
* `reuseInverted` is off deliberately: an inverted (white-on-black)
* code is not something the delivery-note QR ever is, and leaving it
* off measurably cuts false positives on printed labels.
*/
export async function decodeQrFromCanvas(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
width: number,
height: number,
): Promise<string | null> {
const native = await getNativeDetector();
if (native) {
try {
const found = await native.detect(canvas);
const value = found.find((b) => b.rawValue)?.rawValue;
if (value) return value;
// Fall THROUGH to jsQR rather than returning null — a native
// miss on a blurred/angled frame is exactly when the JS decoder
// sometimes still succeeds.
} catch {
/* fall through to the fallback */
}
}
const image = ctx.getImageData(0, 0, width, height);
const result = jsQR(image.data, width, height, { inversionAttempts: "dontInvert" });
return result?.data ?? null;
}
/**
* Run the detector over a video stream until it finds something.
*
* Throttled to ~8 fps: a camera frame is 30-60 fps, and decoding every
* one burns battery for no gain — the QR is not moving relative to the
* page, so once per 120 ms is plenty. Rejects if `signal` aborts so
* the caller can cancel cleanly on unmount or after a hit.
*/
export function scanVideoFrames(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
onFound: (raw: string) => void,
signal: AbortSignal,
): Promise<void> {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) {
return Promise.reject(new Error("Could not get a 2D context for scanning."));
}
const INTERVAL_MS = 120;
return new Promise((resolve) => {
let stopped = false;
const finish = () => {
if (stopped) return;
stopped = true;
clearInterval(timer);
signal.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = () => finish();
signal.addEventListener("abort", onAbort, { once: true });
const timer = setInterval(() => {
if (stopped) return;
if (signal.aborted) return finish();
if (video.readyState < 2 || !video.videoWidth) return;
// Scan a downscaled frame: cheaper to decode, and the code is
// small on screen. 640px wide keeps it reliable.
const scale = Math.min(1, 640 / video.videoWidth);
const w = Math.max(1, Math.round(video.videoWidth * scale));
const h = Math.max(1, Math.round(video.videoHeight * scale));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
ctx.drawImage(video, 0, 0, w, h);
void decodeQrFromCanvas(canvas, ctx, w, h)
.then((raw) => {
if (raw && !stopped) {
onFound(raw);
finish();
}
})
.catch(() => {
/* a single bad frame must not kill the loop */
});
}, INTERVAL_MS);
if (signal.aborted) finish();
});
}

View file

@ -947,6 +947,34 @@ export async function generateDeliveryNoteNumberServer(
}
}
/**
* Did this PocketBase create fail because the delivery note number
* was already taken?
*
* PB surfaces a unique-index violation as a 400 with a per-field
* `data` map, so the reliable check is "the error names our field"
* rather than matching a message string that varies by SDK version.
* The message is checked too as a fallback for the older shape where
* the field map is absent.
*
* Kept narrow on purpose: anything that isn't recognisably a number
* collision (a bad customer id, a PB outage) must propagate, or the
* retry loop would swallow a real failure and retry it five times.
*/
function isDeliveryNoteNumberCollision(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as {
message?: unknown;
status?: unknown;
response?: { status?: unknown; data?: unknown };
data?: { [field: string]: { code?: string; message?: string } };
};
// PB's unique-violation code.
if (e.data?.delivery_note_number?.code === "validation_invalid_unique") return true;
const message = typeof e.message === "string" ? e.message : "";
return /delivery_note_number/i.test(message) && /unique|already exists|violat/i.test(message);
}
/**
* The shape of one line in the ad-hoc "create delivery note"
* form. The customer + qty are required; supplier + prices are
@ -1040,21 +1068,45 @@ export async function createAdHocDeliveryNote(
}
// ── 2. Generate the DN number ─────────────────────────────────
const deliveryNoteNumber = await generateDeliveryNoteNumberServer();
let deliveryNoteNumber = await generateDeliveryNoteNumberServer();
// ── 3. Create the delivery_notes header ──────────────────────
// The route layer is responsible for knowing the auth'd
// user; the service just trusts `createdBy` (which is null
// in tests / cron scenarios).
const header = await pb.collection(DELIVERY_NOTES).create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
customer: input.customer,
company: input.company ?? null,
order: null, // ad-hoc — no parent order
status: "draft",
notes: input.notes ?? "",
created_by: input.createdBy ?? null,
});
//
// Retried on a number collision. `generateDeliveryNoteNumberServer`
// reads the highest existing number and adds one — that read-then-
// write is not atomic, and PB holds a UNIQUE index on
// `delivery_notes.delivery_note_number`. Two creates racing (the
// POS now writes here too, alongside the CRM dialog) can therefore
// pick the same number, and without this loop the loser gets a bare
// 500 from the route for what is really a benign, self-healing
// condition. PB reports this as a validation failure on the field.
let header: DeliveryNote;
for (let attempt = 1; ; attempt++) {
try {
header = await pb.collection(DELIVERY_NOTES).create<DeliveryNote>({
delivery_note_number: deliveryNoteNumber,
customer: input.customer,
company: input.company ?? null,
order: null, // ad-hoc — no parent order
status: "draft",
notes: input.notes ?? "",
created_by: input.createdBy ?? null,
});
break;
} catch (err) {
const isCollision =
attempt < 5 &&
isDeliveryNoteNumberCollision(err);
if (!isCollision) throw err;
console.warn(
`[createAdHocDeliveryNote] ${deliveryNoteNumber} already taken — regenerating (attempt ${attempt + 1})`,
);
deliveryNoteNumber = await generateDeliveryNoteNumberServer();
}
}
// ── 4. Create the order_items rows ────────────────────────────
// Each item is ad-hoc (no parent order) and stamped with the

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { checkoutSale, type PosPb } from "./pos-service";
import type { AdHocDeliveryNoteInput } from "./delivery-notes-service";
/**
* Pure-logic tests for the discount math. Mocks out the PB
@ -225,3 +226,240 @@ describe("checkoutSale — discount math", () => {
expect(saleData.total).toBe(180);
});
});
/**
* Account sales now raise a SYSTEM delivery note server-side, inside
* `checkoutSale`, and the 80mm thermal delivery-note slip is gone from
* that path. These cover the three things that can go wrong:
*
* 1. a DN is raised for the wrong tender (or not at all for the
* right one)
* 2. the till's offline queue re-posts `/checkout` and creates a
* SECOND delivery note for one physical delivery
* 3. a DN failure takes the sale down with it
*
* `createDeliveryNote` is injected rather than real, because
* `createAdHocDeliveryNote` opens its own admin PocketBase client and
* a stubbed `PosPb` can't intercept it.
*/
describe("checkoutSale — account delivery note", () => {
const STOCK = { id: "si-1", sku: "WID-1", cost_price: 50, current_qty: 100 };
type Fake = PosPb & {
saleCreates: Array<Record<string, unknown>>;
saleUpdates: Array<Record<string, unknown>>;
setExisting: (row: Record<string, unknown> | null) => void;
};
function accountPb(existing: Record<string, unknown> | null = null): Fake {
const saleCreates: Array<Record<string, unknown>> = [];
const saleUpdates: Array<Record<string, unknown>> = [];
let existingRow = existing;
const base = {
getFullList: async () => [],
getList: async () => ({ items: existingRow ? [existingRow] : [] }),
getOne: async () => {
throw new Error("404");
},
create: async (d: Record<string, unknown>) => ({ id: "x", ...d }),
update: async (_id: string, d: Record<string, unknown>) => {
saleUpdates.push(d);
return {};
},
};
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return { ...base, getFullList: async () => [STOCK], getList: async () => ({ items: [STOCK] }), getOne: async () => STOCK };
}
if (name === "pos_sales") {
return {
...base,
create: async (d: Record<string, unknown>) => {
saleCreates.push(d);
return { id: "sale-1", ...d };
},
update: async (_id: string, d: Record<string, unknown>) => {
saleUpdates.push(d);
return {};
},
};
}
if (name === "pos_sale_items") {
return { ...base, create: async (d: Record<string, unknown>) => ({ id: "line-1", ...d }) };
}
if (name === "stock_ledger") return { ...base, create: async () => ({ id: "led-1" }) };
if (name === "customers") {
return { ...base, getOne: async () => ({ id: "cust-1", company: "comp-1" }) };
}
if (name === "delivery_notes") {
return { ...base, getOne: async () => ({ id: "dn-1", delivery_note_number: "DN26100301" }) };
}
return base;
},
};
return Object.assign(pb as unknown as Fake, {
saleCreates,
saleUpdates,
setExisting: (row: Record<string, unknown> | null) => {
existingRow = row;
},
});
}
function stubCreate() {
// Typed with the input parameter so `.mock.calls[0][0]` is typed
// rather than an empty tuple.
return vi.fn(async (_input: AdHocDeliveryNoteInput) => ({
id: "dn-1",
delivery_note_number: "DN26100301",
}));
}
it("raises a delivery note for an account sale and stamps it on the sale", async () => {
const pb = accountPb();
const createDeliveryNote = stubCreate();
const result = await checkoutSale(
pb,
{
items: [{ stock_item_id: "si-1", qty: 2, unit_price: 115, sku: "WID-1", description: "Wedge" }],
payment_method: "account",
customer_id: "cust-1",
},
{ createDeliveryNote },
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.deliveryNote?.delivery_note_number).toBe("DN26100301");
// The pdfPath must be the POS-scoped route — the till's bearer key
// is the only credential it holds, so it must not be pointed at the
// CRM's unauthenticated invoicing route.
expect(result.deliveryNote?.pdfPath).toBe("/delivery-note/DN26100301/pdf");
// Company is derived from the customer, never sent by the till.
expect(createDeliveryNote).toHaveBeenCalledTimes(1);
const arg = createDeliveryNote.mock.calls[0][0];
expect(arg.customer).toBe("cust-1");
expect(arg.company).toBe("comp-1");
expect(arg.items).toEqual([
expect.objectContaining({ description: "Wedge", quantity: 2, cost_price: 50, selling_price: 115, sku: "WID-1" }),
]);
// The idempotency anchor is written, or a replay would re-create it.
expect(pb.saleUpdates).toContainEqual({ delivery_note: "dn-1" });
});
it("does NOT raise a delivery note for a cash sale", async () => {
const pb = accountPb();
const createDeliveryNote = stubCreate();
const result = await checkoutSale(
pb,
{ items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }], payment_method: "cash" },
{ createDeliveryNote },
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(createDeliveryNote).not.toHaveBeenCalled();
expect(result.deliveryNote).toBeNull();
// (saleUpdates also carries the stock_items qty write, so assert on
// the shape rather than the count.)
expect(pb.saleUpdates.some((u) => "delivery_note" in u)).toBe(false);
});
it("replays the EXISTING delivery note on a duplicate client_ref instead of making a second", async () => {
// This is the offline-queue case: the sale banked, the response was
// lost, and the till re-posts the same client_ref on reconnect.
const pb = accountPb({
id: "sale-1",
client_ref: "local-abc",
payment_method: "account",
customer: "cust-1",
delivery_note: "dn-1",
});
const createDeliveryNote = stubCreate();
const result = await checkoutSale(
pb,
{
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "account",
customer_id: "cust-1",
client_ref: "local-abc",
},
{ createDeliveryNote },
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.duplicate).toBe(true);
expect(createDeliveryNote).not.toHaveBeenCalled();
expect(result.deliveryNote?.delivery_note_number).toBe("DN26100301");
});
it("retries the delivery note on a replay where the first attempt left it blank", async () => {
// A sale whose DN creation failed leaves delivery_note empty. The
// offline retry is the natural place to heal that.
const pb = accountPb({
id: "sale-1",
client_ref: "local-abc",
payment_method: "account",
customer: "cust-1",
delivery_note: "",
});
const createDeliveryNote = stubCreate();
const result = await checkoutSale(
pb,
{
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "account",
customer_id: "cust-1",
client_ref: "local-abc",
},
{ createDeliveryNote },
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.duplicate).toBe(true);
expect(createDeliveryNote).toHaveBeenCalledTimes(1);
expect(result.deliveryNote?.id).toBe("dn-1");
});
it("keeps the sale when the delivery note cannot be created", async () => {
const pb = accountPb();
const createDeliveryNote = vi.fn(async () => {
throw new Error("PocketBase is down");
});
const result = await checkoutSale(
pb,
{
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "account",
customer_id: "cust-1",
},
{ createDeliveryNote },
);
// The sale and its stock movement are real — failing the checkout
// would leave the drawer disagreeing with the stock ledger.
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.deliveryNote).toBeNull();
expect(result.deliveryNoteError).toContain("PocketBase is down");
expect(pb.saleCreates.length).toBeGreaterThan(0);
});
it("flags an account sale with no customer rather than silently dropping the delivery note", async () => {
const pb = accountPb();
const createDeliveryNote = stubCreate();
const result = await checkoutSale(
pb,
{ items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }], payment_method: "account" },
{ createDeliveryNote },
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(createDeliveryNote).not.toHaveBeenCalled();
expect(result.deliveryNote).toBeNull();
expect(result.deliveryNoteError).toContain("no customer");
});
});

View file

@ -6,6 +6,25 @@ import type {
PosSaleItem,
StockItem,
} from "@/lib/types";
import {
createAdHocDeliveryNote,
type AdHocDeliveryNoteInput,
type AdHocDeliveryNoteResult,
} from "@/lib/server/delivery-notes-service";
/**
* Injectable dependencies for `checkoutSale`.
*
* `createDeliveryNote` exists so the delivery-note path can be tested
* without a live PocketBase: `createAdHocDeliveryNote` opens its own
* admin client internally, so a stubbed `PosPb` can't intercept it.
* Production passes nothing and gets the real service.
*/
export type PosCheckoutDeps = {
createDeliveryNote?: (input: AdHocDeliveryNoteInput) => Promise<AdHocDeliveryNoteResult>;
};
type CreateDeliveryNoteFn = (input: AdHocDeliveryNoteInput) => Promise<AdHocDeliveryNoteResult>;
/**
* PB client type kept loose so both the real `PocketBase` instance
@ -205,6 +224,25 @@ export type PosCheckoutResult =
stockErrors: Array<{ stock_item_id: string; error: string }>;
/** True when this was a replay of an already-recorded `client_ref`. */
duplicate: boolean;
/**
* The system delivery note raised for this sale. Non-null ONLY
* for `payment_method = "account"` sales that had a customer
* picked — cash / card / EFT get the 80mm thermal slip instead.
*
* The till prints `dnPdfPath` on an A4 office printer and hands
* the client two copies. The 80mm thermal delivery-note slip is
* no longer produced for account sales.
*/
deliveryNote: PosSaleDeliveryNote | null;
/**
* Set when an account sale banked but its delivery note could
* not be created. The sale is NOT rolled back — the money and
* the stock movement are real and correct. The till surfaces
* this so the operator can raise the DN by hand from the CRM
* rather than discovering a missing delivery note at invoicing
* time. Absent on every other tender type.
*/
deliveryNoteError?: string;
}
| {
ok: false;
@ -212,6 +250,19 @@ export type PosCheckoutResult =
message: string;
};
/** The delivery note an account sale is linked to, as the till sees it. */
export type PosSaleDeliveryNote = {
id: string;
delivery_note_number: string;
/**
* Same-origin path the till opens in an iframe to print. Goes
* through `assertPosAuth` — the till's bearer key is the only
* credential it holds, so this must NOT point at the CRM's
* unauthenticated `/api/invoicing/delivery-note-pdf`.
*/
pdfPath: string;
};
/** PB filter strings are built by hand here, so quote-escape anything user-supplied. */
function escapePbValue(value: string): string {
return value.replace(/'/g, "\\'");
@ -237,6 +288,130 @@ async function findSaleByClientRef(
return { sale, items: itemsPage.items };
}
/**
* Creates (or recovers) the system delivery note for an `account`
* sale. No-op for every other tender — cash / card / EFT keep the
* 80mm thermal slip and never touch `delivery_notes`.
*
* Runs on BOTH the fresh-sale path and the idempotent-replay path.
* That second call is the whole reason this lives inside
* `checkoutSale` rather than being a second call the till makes after
* the fact: the till's offline queue (`flushQueue` in
* `public/pos/pos-app.js`) re-posts `/checkout` and nothing else, so
* a client-side follow-up call would be silently dropped for every
* sale taken while the network was down. Folding it in here means the
* delivery note is created on whichever attempt actually reaches the
* server.
*
* `pos_sales.delivery_note` is the anchor that makes it exactly-once:
* if it's already set we hand back the existing DN rather than making
* a second one. That also self-heals — a sale whose first DN creation
* failed leaves the field blank, so the retry tries again.
*
* Failures are deliberately non-fatal. By this point the sale is
* banked and stock has moved; a missing delivery note is a paperwork
* gap the operator can close by hand, whereas failing the whole
* checkout would leave the drawer and the stock ledger disagreeing
* with reality. The error is returned so the till can shout about it.
*/
async function ensureSaleDeliveryNote(
pb: PosPb,
sale: PosSale,
items: PosSaleItem[],
createDeliveryNote: CreateDeliveryNoteFn,
): Promise<{ deliveryNote: PosSaleDeliveryNote | null; deliveryNoteError?: string }> {
if (sale.payment_method !== "account") {
return { deliveryNote: null };
}
if (!sale.customer) {
// The till already blocks an account sale with no customer picked
// (`validateConfirm`), so this is a belt-and-braces guard for a
// hand-rolled / replayed request. Surface it rather than silently
// producing an account sale with no paperwork.
return {
deliveryNote: null,
deliveryNoteError:
"Account sale with no customer — no delivery note was created. Raise one by hand from the CRM.",
};
}
// Already linked (fresh read or a replay) — hand back what exists.
if (sale.delivery_note) {
try {
const dn = await pb
.collection("delivery_notes")
.getOne<{ id: string; delivery_note_number: string }>(sale.delivery_note);
return { deliveryNote: toPosSaleDeliveryNote(dn) };
} catch {
// The relation points at a row that no longer exists. Fall
// through and try to create a replacement rather than stranding
// the sale with an unreadable link.
}
}
try {
const created = await createDeliveryNote({
// Company is deliberately NOT passed: createAdHocDeliveryNote's
// route derives it from `customer.company` so the operator can't
// produce a customer/company mismatch. We do the same lookup
// here because we call the service directly, not the route.
customer: sale.customer,
company: await deriveCompanyId(pb, sale.customer),
notes: "Raised at the POS on an account sale.",
items: items.map((it) => ({
description: it.description || "",
quantity: Number(it.qty ?? 0),
cost_price: Number(it.unit_cost ?? 0),
selling_price: Number(it.unit_price ?? 0),
sku: it.sku || "",
})),
createdBy: null,
});
// Stamp the anchor. If this write fails the DN still exists and is
// printable, but a replay would try again — which is why the
// unique-index retry in the create service matters.
try {
await pb.collection("pos_sales").update(sale.id, { delivery_note: created.id });
} catch (err) {
console.error(
"[pos.checkout] delivery note created but pos_sales.delivery_note not stamped:",
err,
);
}
return { deliveryNote: toPosSaleDeliveryNote(created) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error("[pos.checkout] delivery note creation failed:", message);
return {
deliveryNote: null,
deliveryNoteError: `Sale completed but the delivery note could not be created (${message}). Raise one by hand from the CRM.`,
};
}
}
function toPosSaleDeliveryNote(dn: { id: string; delivery_note_number: string }): PosSaleDeliveryNote {
return {
id: dn.id,
delivery_note_number: dn.delivery_note_number,
pdfPath: `/delivery-note/${encodeURIComponent(dn.delivery_note_number)}/pdf`,
};
}
/**
* The customer's own company relation. Mirrors the derive step in
* `POST /api/v1/delivery-notes` so both create paths agree.
*/
async function deriveCompanyId(pb: PosPb, customerId: string): Promise<string | null> {
try {
const row = await pb.collection("customers").getOne<{ company?: string }>(customerId);
return row.company && row.company.length > 0 ? row.company : null;
} catch {
// An unreadable customer fails later in the create service with a
// clearer message. Don't pre-empt it here.
return null;
}
}
/**
* Confirms a POS sale: validates stock availability, creates the
* `pos_sales`/`pos_sale_items` rows, then for each line writes a
@ -269,7 +444,9 @@ async function findSaleByClientRef(
export async function checkoutSale(
pb: PosPb,
input: PosCheckoutInput,
deps: PosCheckoutDeps = {},
): Promise<PosCheckoutResult> {
const createDeliveryNote = deps.createDeliveryNote ?? createAdHocDeliveryNote;
if (!Array.isArray(input.items) || input.items.length === 0) {
return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
}
@ -290,7 +467,25 @@ export async function checkoutSale(
try {
const existing = await findSaleByClientRef(pb, clientRef);
if (existing) {
return { ok: true, sale: existing.sale, items: existing.items, stockErrors: [], duplicate: true };
// The delivery note is recovered here, not re-created: an
// offline sale replayed on reconnect must return the SAME DN
// the first successful attempt produced, or the client gets
// two documents for one delivery.
const { deliveryNote, deliveryNoteError } = await ensureSaleDeliveryNote(
pb,
existing.sale,
existing.items,
createDeliveryNote,
);
return {
ok: true,
sale: existing.sale,
items: existing.items,
stockErrors: [],
duplicate: true,
deliveryNote,
...(deliveryNoteError ? { deliveryNoteError } : {}),
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@ -420,7 +615,22 @@ export async function checkoutSale(
}
}
return { ok: true, sale, items, stockErrors, duplicate: false };
const { deliveryNote, deliveryNoteError } = await ensureSaleDeliveryNote(
pb,
sale,
items,
createDeliveryNote,
);
return {
ok: true,
sale,
items,
stockErrors,
duplicate: false,
deliveryNote,
...(deliveryNoteError ? { deliveryNoteError } : {}),
};
}
// ─── listSalesInRange ───────────────────────────────────────────────
@ -441,6 +651,18 @@ export async function listSalesInRange(
const salesPage = await pb.collection("pos_sales").getList<PosSale>(1, 500, {
filter: `created >= '${escapePbValue(range.from)}' && created <= '${escapePbValue(range.to)}'`,
sort: "-created",
// Expand the relations `history.html` needs to rebuild the delivery
// note card on a reprint:
// customer — the "Attention:" name
// customer.company — the company NAME. `customer.company` is
// itself a relation, so without the nested expand the card
// printed a raw PB id ("rpqogiu03d97lcv") where the company
// name belongs. PB's syntax for a nested expand prefixes
// every level.
// delivery_note — the DN NUMBER, not just the relation id,
// because that is what the till's PDF route is keyed on.
// PB resolves all of these in one extra query, not one per row.
expand: "customer,customer.company,delivery_note",
});
const sales = salesPage.items;
if (sales.length === 0) return { sales: [], items: [] };
@ -458,6 +680,11 @@ export type PosCustomerResult = { id: string; name: string; company: string };
* Customer picker for account sales. Lives behind the POS API rather
* than being a direct PB query from the till — the whole point of this
* app's rewrite is that no PocketBase credentials reach the client.
*
* `company` is the company's display name. It is NOT sent onward as
* the DN's company — `createAdHocDeliveryNote` derives that from the
* customer's own `company` relation server-side, which is the only
* value that can't drift. The till is a display surface here.
*/
export async function searchCustomersForPos(
pb: PosPb,

View file

@ -391,9 +391,11 @@ export async function generateQuotePdf(
: null;
// ── 5. Render ────────────────────────────────────────────
// Letterhead + logo (SVG as a data URI). @react-pdf v4.5.1
// corrupts any raster (PNG/JPEG) byte ≥ 0x80, so we ship SVG
// instead.
// Letterhead + logo. The logo ships as an SVG data URI because
// `public/letterhead/*.svg` is an SVG source file — the raster
// corruption this comment used to blame on @react-pdf was
// actually the old `pdf().toString()` render pipeline. See the
// note at the top of `lib/pdf/render.tsx`.
const letterhead = await fetchLetterhead(letterheadCode);
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);

View file

@ -1703,6 +1703,36 @@ export interface PosSale extends BaseRecord {
* this returns the original sale instead of decrementing stock twice.
*/
client_ref: string;
/**
* The system `delivery_notes` record raised for this sale, set only
* on `payment_method = "account"` sales that had a customer picked.
*
* This is the idempotency anchor for the delivery note. The till's
* offline queue re-posts `/checkout` with the same `client_ref` when
* the network drops, and `checkoutSale` replays the original sale
* untouched — reading this field first, so one physical sale can
* never produce two delivery notes. Blank means "no DN yet", which
* is also the retry signal if the first creation attempt failed.
*
* Cash / card / EFT sales leave it blank forever — they get the
* 80mm thermal slip, not a delivery note.
*/
delivery_note?: string;
/**
* Present on rows from `GET /api/v1/pos/sales` (history.html and
* cashup.html), which expands the customer and the linked delivery
* note so the till can rebuild the DN card on a reprint. Absent
* elsewhere.
*/
expand?: {
customer?: {
id?: string;
customer?: string;
company?: string;
expand?: { company?: { id?: string; company?: string } };
};
delivery_note?: { id?: string; delivery_note_number?: string };
};
}
/** How the operator entered the discount on the till. */