From 8232a87f9968309ea6da1b90c126bc7675e470b3 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 12 Oct 2026 22:54:09 +0200 Subject: [PATCH] feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the POS sale pipeline, all in one commit because pos-service.ts / types.ts / pos-app.js are each touched by all three and splitting them would only produce intermediate broken states. VAT is now charged on top, not backed out Catalogue prices are ex-VAT: `selling_price` is derived as `cost_price x markup` from the supplier's ex-VAT cost, with no VAT uplift anywhere (verified — median selling/cost across the catalogue is exactly 1.8). The till nevertheless treated them as VAT-inclusive and recovered the net with `total / 1.15`, so every sale was recorded with an understated net and an understated VAT line. An R100 ex-VAT line now rings up as R115. The rate comes from the till's letterhead, read server-side rather than accepted off the wire, and is stored per sale so a later letterhead rate change cannot rewrite what was charged. All 3,623 catalogue prices are untouched. Ad-hoc "custom" lines A cashier can now ring up something that was never a catalogue item — "bolt cutting" at R20 agreed at the counter — via a new "+ Add a custom item" composer. The line prints on the slip and lands in pos_sale_items, but carries no stock_item relation and takes no stock movement: no ledger row, no current_qty decrement, no catalogue entry. pos_sale_items.stock_item is no longer required at the schema level and an explicit `is_custom` flag marks the line, rather than inferring it from a blank relation — so "how much did we make on bolt cutting this month" is a plain query, and a future stock-less line type cannot be confused with a corrupt one. Custom lines are refused on account sales: those raise a delivery note the customer signs for and feed their statement, so an ad-hoc charge must not slip onto one. Enforced server-side, not just in the UI. Discounts are now 20% or 40%, nothing else The R-off / %-off pills and the free-typed number box are gone. The server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses anything outside it rather than coercing it, so a stale till cannot invent a discount. The discount lands on the ex-VAT subtotal, making "20% off" a true 20% off both the net and the VAT on it. Two defects found while doing this pos_sales was missing six fields that checkoutSale has been writing all along (gross_subtotal, gross_vat, gross_total, discount_amount, discount_kind, discount_input). PocketBase accepts unknown fields without error and silently drops them, so all 192 historical sales lack them and a discounted sale reprinted as though no discount had been applied. Added, plus vat_percent. History also passed gross_total (VAT-inclusive) where the receipt derives the gross VAT from an ex-VAT base — a reprint double-counted VAT. Corrected. Verification: 3860 tests pass (14 new); typecheck, lint and production build clean; live PocketBase probe confirms a pos_sale_items row with stock_item="" and is_custom=true round-trips, with the probe removed afterwards. --- public/pos/history.html | 8 +- public/pos/pos-app.js | 262 +++++----- public/pos/pos.html | 58 ++- public/pos/receipt.html | 45 +- src/app/api/v1/pos/checkout/route.ts | 25 +- src/app/api/v1/pos/sales/[id]/refund/route.ts | 3 + src/lib/pos-cart-quantity.test.ts | 15 +- src/lib/server/pos-service.test.ts | 454 +++++++++++++----- src/lib/server/pos-service.ts | 171 +++++-- src/lib/types.ts | 71 ++- 10 files changed, 811 insertions(+), 301 deletions(-) diff --git a/public/pos/history.html b/public/pos/history.html index 2dcd3a5..2e9cd38 100644 --- a/public/pos/history.html +++ b/public/pos/history.html @@ -330,7 +330,10 @@ window.reprint = function(orderId){ payment: s.payment_method, cashGiven: s.cash_given || 0, change: s.change_given || 0, - gross: s.gross_total, + // `gross_subtotal` is the EX-VAT pre-discount net — the receipt + // derives the gross VAT from it. `gross_total` is that figure + // plus VAT, so passing it here would double-count the VAT. + gross: s.gross_subtotal, discount: { kind: s.discount_kind || 'none', input: s.discount_input || 0, @@ -339,6 +342,9 @@ window.reprint = function(orderId){ sub: s.subtotal, vat: s.vat, total: s.total, + // The rate this sale was rung up at, so the reprint labels its + // VAT line correctly even after a letterhead rate change. + vatPct: s.vat_percent, items: row.items.map(it => ({ sku: it.sku, description: it.description, diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 6fbfd6f..2556394 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -315,6 +315,64 @@ function initials(s){ return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2); } +// ---------------- custom (ad-hoc) lines ---------------- +// +// A line the cashier types by hand — "Bolt cutting" at R20 — that +// belongs on this slip and nowhere else. It is NOT a catalogue +// item: no `stock_items` row, no stock decrement, no ledger entry. +// It carries a synthetic `custom:` id purely so it can live in the +// same cart Map and flow through the same totals, slip and checkout +// path as everything else. +// +// The prefix matters: it's how the checkout payload marks the line +// `is_custom`, and it's how the cart drawer badges it so the +// cashier can see at a glance which lines are not real stock. +const CUSTOM_ID_PREFIX = 'custom:'; + +function addCustomItem(description, price){ + const desc = (description||'').trim(); + const amount = Number(price); + if(!desc){ alert('Give the item a name.'); return null; } + if(!Number.isFinite(amount) || amount <= 0){ alert('Enter a price greater than zero.'); return null; } + const p = { + id: CUSTOM_ID_PREFIX + uid(), + name: desc, + description: desc, + sku: '', + unit: '', + // EX-VAT, same basis as the catalogue — VAT is added on top in + // cartTotals(). A service the shop performs has no cost of goods. + selling_price: amount, + cost_price: 0, + is_custom: true, + }; + state.cart.set(p.id, {p, qty: 1}); + persistCart(); + render(); + paintCart(); + renderCartDrawer(); + return p; +} +function isCustomLine(p){ return !!(p && p.is_custom); } + +function openCustomItem(){ + document.getElementById('customDesc').value = ''; + document.getElementById('customPrice').value = ''; + document.getElementById('customSheet').classList.add('show'); + document.getElementById('customBackdrop').classList.add('show'); + setTimeout(()=>document.getElementById('customDesc').focus(), 60); +} +function closeCustomItem(){ + document.getElementById('customSheet').classList.remove('show'); + document.getElementById('customBackdrop').classList.remove('show'); +} +function saveCustomItem(){ + const desc = document.getElementById('customDesc').value; + const price = document.getElementById('customPrice').value.replace(/[^0-9.]/g,''); + const p = addCustomItem(desc, Number(price)); + if(p) closeCustomItem(); +} + // ---------------- cart ---------------- function addToCart(id){ const p = state.productsById.get(id); @@ -351,36 +409,30 @@ function cartTotals(){ for(const {p,qty} of state.cart.values()){ count += qty; // Catalogue shelf price — the till never edits a line's price. + // Prices are EX-VAT (see pos-service.ts): derived as + // cost × markup from the supplier's ex-VAT cost, with no VAT + // uplift. VAT is added on top below. const unit = lineUnitPrice({p,qty}); gross += unit * qty; } - // Discount (off the gross, post-VAT — same convention the - // server uses). Cap at the gross total so a fat-finger % - // can't drive the post-discount total negative. + // Discount (off the ex-VAT gross, so it's a true % off both the + // net and the VAT on it). Percentage-only and capped at the + // gross so the total can never go negative. const d = state.discount || { kind: 'none', input: 0 }; let discountAmount = 0; - if (d.kind === 'amount' && d.input > 0) { - discountAmount = Math.min(d.input, gross); - } else if (d.kind === 'percent' && d.input > 0) { + if (d.kind === 'percent' && d.input > 0) { discountAmount = Math.min((gross * d.input) / 100, gross); } - // VAT-inclusive model: selling_price is the shelf price (incl. VAT). - // The VAT rate is driven by the active letterhead (default 15% if - // no letterhead is loaded yet — same as the previous hardcoded - // behaviour, so a fresh till on a flaky network still works). - const vatPct = Number(state.letterhead && state.letterhead.vat_percent) || 15; - const total = Math.max(0, gross - discountAmount); - let sub, vat; - if (vatPct > 0) { - const factor = 1 + vatPct / 100; - sub = total / factor; - vat = total - sub; - } else { - // 0% VAT (e.g. CNL / ISHERWOOD) — no VAT line, sub == total. - sub = total; - vat = 0; - } - return {count, gross, discountAmount, sub, vat, total, vatPct}; + // The VAT rate comes from the active letterhead — each shop may + // sit at a different rate. Falls back to the SA standard rate + // when no letterhead has loaded yet (a fresh till on a flaky + // network still has to be able to take money). + const vatPct = Number(state.letterhead && state.letterhead.vat_percent); + const pct = Number.isFinite(vatPct) && vatPct >= 0 ? vatPct : 15; + const net = Math.max(0, gross - discountAmount); + const vat = (net * pct) / 100; + const total = net + vat; + return {count, gross, discountAmount, sub: net, vat, total, vatPct: pct}; } /** @@ -431,8 +483,15 @@ function renderCartDrawer(){ } body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{ const price = lineUnitPrice({p,qty}); - return `
-
${escapeHTML(p.description||p.sku)}${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}
+ const custom = isCustomLine(p); + // Custom lines carry no SKU and no unit — badge them so the + // cashier can see at a glance that this isn't stock, and it + // won't show up on a stocktake or reorder list. + const sub = custom + ? `${fmt(price)} each · custom item` + : `${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}`; + return `
+
${escapeHTML(p.description||p.name||p.sku)}${sub}
@@ -448,120 +507,94 @@ function renderCartDrawer(){ document.getElementById('subTotal').textContent = fmt(t.sub); document.getElementById('vatTotal').textContent = fmt(t.vat); document.getElementById('grandTotal').textContent = fmt(t.total); + // The VAT line label follows the letterhead's rate rather than + // hardcoding 15%, so a shop at a different rate doesn't print a + // wrong label over the right number. + const vatLabel = document.getElementById('vatLabel'); + if (vatLabel) vatLabel.textContent = t.vatPct > 0 ? `VAT ${t.vatPct}%` : 'VAT'; // Discount line: shown only when a discount is actually // applied. The server re-derives the resolved rand value at // commit time, so this number is a preview only. const dl = document.getElementById('discountLine'); if (t.discountAmount > 0) { dl.hidden = false; - document.getElementById('discountLineAmount').textContent = '− ' + fmt(t.discountAmount); + const d = state.discount || {}; + const pctLabel = d.kind === 'percent' && d.input ? ` (${d.input}%)` : ''; + document.getElementById('discountLineAmount').textContent = + `− ${fmt(t.discountAmount)}${pctLabel} ex VAT`; } else { dl.hidden = true; } } // ---------------- discount ---------------- +// Two fixed tiers, 20% and 40%. Deliberately not a free-typed +// field: the whole point is that every discount on every slip is +// visibly one of two known-good choices, so nobody can ring up a +// hand-typed 90% (or a random rand amount) and nobody has to guess +// afterwards whether it was deliberate. +const DISCOUNT_TIERS = [20, 40]; + function paintDiscount(){ - // Reset the pill state + visibility based on current state. const d = state.discount || { kind: 'none', input: 0 }; const trig = document.getElementById('discountTrigger'); const lbl = document.getElementById('discountTriggerLabel'); - const amount = document.getElementById('discKindAmount'); - const percent = document.getElementById('discKindPercent'); - const input = document.getElementById('discInput'); - const suffix = document.getElementById('discSuffix'); + const body = document.getElementById('discountBody'); const hint = document.getElementById('discHint'); - // Default the kind pill to "amount" if the user has never - // picked one — most "5 rand off" discounts are R off, not %. - const activeKind = d.kind === 'percent' ? 'percent' : 'amount'; - amount.classList.toggle('active', activeKind === 'amount'); - percent.classList.toggle('active', activeKind === 'percent'); - suffix.textContent = activeKind === 'percent' ? '%' : 'R'; - if (d.kind !== 'none' && d.input > 0) { + const active = d.kind === 'percent' && DISCOUNT_TIERS.includes(Number(d.input)) + ? Number(d.input) : 0; + + for (const tier of DISCOUNT_TIERS){ + const btn = document.getElementById('discTier'+tier); + if (btn) btn.classList.toggle('active', tier === active); + } + if (active){ trig.classList.add('has-value'); - if (d.kind === 'percent') { - lbl.textContent = `Discount: ${d.input}%`; - } else { - lbl.textContent = `Discount: R ${d.input.toFixed(2)}`; - } - document.getElementById('discountBody').hidden = false; - trig.setAttribute('aria-expanded', 'true'); - if (input.value === '') input.value = String(d.input); + lbl.textContent = `Discount: ${active}%`; + body.hidden = false; + trig.setAttribute('aria-expanded','true'); } else { trig.classList.remove('has-value'); lbl.textContent = 'Add discount'; - document.getElementById('discountBody').hidden = true; - trig.setAttribute('aria-expanded', 'false'); - input.value = ''; + body.hidden = true; + trig.setAttribute('aria-expanded','false'); } - hint.textContent = d.kind === 'percent' - ? 'A percentage off the cart total (post-VAT).' - : 'A rand amount off the cart total (post-VAT).'; + hint.textContent = 'Applied off the ex-VAT total — VAT is charged on the discounted amount.'; } function toggleDiscount(){ - // If the body is hidden, show it; if it's already open, close - // it (and clear the current discount if the operator hits the - // "X" — i.e. the chevron). For v1 the chevron toggles open - // state only; clearing happens via the explicit Apply/Clear - // buttons. + // The trigger only opens/closes the tier buttons — it never clears + // the discount. Clearing is tapping the active tier again, so + // there's no way to lose a discount by mis-tapping the chevron. const body = document.getElementById('discountBody'); const trig = document.getElementById('discountTrigger'); - if (body.hidden) { - body.hidden = false; - trig.setAttribute('aria-expanded', 'true'); - setTimeout(()=>document.getElementById('discInput').focus(), 60); - } else { - body.hidden = true; - trig.setAttribute('aria-expanded', 'false'); - } + const open = body.hidden; + // Don't let the operator dismiss the panel out from under an + // applied discount — that would hide the reason for the total. + if (!open && state.discount && state.discount.kind === 'percent') return; + body.hidden = !open; + trig.setAttribute('aria-expanded', String(open)); } -function setDiscountKind(k){ - if (k !== 'amount' && k !== 'percent') return; - // Switching kind while a value is present: keep the value, - // re-label, re-render so the suffix and totals update. The - // operator can then tap Apply to confirm or just watch the - // cart total change live. - state.discount = { ...(state.discount || { input: 0 }), kind: k }; - paintDiscount(); - renderCartDrawer(); -} -function applyDiscount(){ - const raw = (document.getElementById('discInput').value || '').replace(/[^0-9.]/g, ''); - const v = Number(raw); - if (!Number.isFinite(v) || v <= 0) { - // Empty / 0 / non-numeric clears the discount. Same effect - // as the explicit clearDiscount() — kept as a single path - // so the totals always reflect what the input box says. +function setDiscountTier(pct){ + const tier = Number(pct); + if(!DISCOUNT_TIERS.includes(tier)) return; + // Tapping the active tier clears it — one control, no separate + // "remove" affordance to forget. + if(state.discount && state.discount.kind==='percent' && Number(state.discount.input)===tier){ clearDiscount(); return; } - state.discount = { kind: state.discount.kind === 'percent' ? 'percent' : 'amount', input: v }; + state.discount = { kind:'percent', input: tier }; paintDiscount(); renderCartDrawer(); } function clearDiscount(){ - state.discount = { kind: 'none', input: 0 }; - document.getElementById('discInput').value = ''; + state.discount = { kind:'none', input:0 }; paintDiscount(); renderCartDrawer(); } -// Live-update the input as the cashier types so the totals move -// with the digits (server re-derives the final number at commit -// time — this is just the preview). -document.addEventListener('input', e=>{ - if (e.target.id !== 'discInput') return; - const raw = e.target.value.replace(/[^0-9.]/g, ''); - e.target.value = raw; - const v = Number(raw); - if (!Number.isFinite(v) || v <= 0) { - state.discount = { kind: 'none', input: 0 }; - } else { - const kind = state.discount.kind === 'percent' ? 'percent' : 'amount'; - state.discount = { kind, input: v }; - } - renderCartDrawer(); -}); +// Discounts are two fixed buttons now, so there's no free-text +// input to live-listen to. // ---------------- long-press edit line ---------------- // @@ -865,6 +898,9 @@ async function confirmSale(){ gross: totals.gross, discount: { kind: d.kind, input: d.input, amount: totals.discountAmount }, sub, vat, total, + // Which VAT rate produced those numbers — the receipt labels + // its VAT line from this rather than hardcoding 15%. + vatPct: totals.vatPct, // Attach the active letterhead so the on-screen receipt AND // the printed slip can show the right shop name, address, // phone, VAT number, footer, and logo. receipt.html's @@ -885,15 +921,20 @@ async function confirmSale(){ const checkoutBody = { items: Array.from(state.cart.values()).map(({p,qty})=>({ - stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty}), - sku: p.sku, description: p.description, + stock_item_id: isCustomLine(p) ? undefined : p.id, + is_custom: isCustomLine(p), + qty, unit_price: lineUnitPrice({p,qty}), + sku: p.sku, description: isCustomLine(p) ? p.description : (p.description || p.sku), })), payment_method: state.paymentMethod, cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined, customer_id: state.customer ? state.customer.id : undefined, client_ref: localId, - discount_kind: state.discount && state.discount.kind !== 'none' ? state.discount.kind : undefined, - discount_input: state.discount && state.discount.kind !== 'none' ? state.discount.input : undefined, + // The server reads the VAT rate off this letterhead rather than + // trusting a rate off the wire. + letterhead_id: state.letterhead ? state.letterhead.id : undefined, + discount_kind: state.discount && state.discount.kind === 'percent' ? 'percent' : undefined, + discount_input: state.discount && state.discount.kind === 'percent' ? state.discount.input : undefined, }; // Distinguish a permanent server-side rejection (insufficient @@ -1032,7 +1073,7 @@ function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&','< function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'`'); } // expose for inline handlers -window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale, openEditLine, toggleDiscount, setDiscountKind, applyDiscount, clearDiscount }; +window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale, openEditLine, addCustomItem, openCustomItem, closeCustomItem, saveCustomItem, setDiscountTier, clearDiscount, toggleDiscount }; // Also expose as bare globals so inline onclick="" in pos.html works window.openDrawer = openDrawer; window.closeDrawer = closeDrawer; @@ -1050,10 +1091,15 @@ window.closeEditLine = closeEditLine; window.editSave = editSave; window.editRemove = editRemove; window.clearCust = clearCust; +// Discount tiers + the ad-hoc line composer are driven from inline +// onclick in pos.html, so they have to be globals too. +window.setDiscountTier = setDiscountTier; window.toggleDiscount = toggleDiscount; -window.setDiscountKind = setDiscountKind; -window.applyDiscount = applyDiscount; window.clearDiscount = clearDiscount; +window.addCustomItem = addCustomItem; +window.openCustomItem = openCustomItem; +window.closeCustomItem = closeCustomItem; +window.saveCustomItem = saveCustomItem; // ---------------- boot ---------------- (async function boot(){ diff --git a/public/pos/pos.html b/public/pos/pos.html index 67aa74c..eb6466c 100644 --- a/public/pos/pos.html +++ b/public/pos/pos.html @@ -121,6 +121,17 @@ input{font:inherit;color:inherit} .totals b{color:var(--fg);font-variant-numeric:tabular-nums} .totals.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:17px;color:var(--fg);font-weight:700;letter-spacing:-.01em} +/* ---- Ad-hoc (custom) line ---- */ +/* A dashed outline rather than a filled button: this is a secondary + action next to the real catalogue, and it should read as "something + extra" rather than compete with the checkout CTA below it. */ +.custom-item-btn{width:100%;margin-bottom:12px;padding:10px 12px;border:1px dashed #c9c9c9;border-radius:9px;background:none;color:#717171;font-size:13px;font-weight:600;letter-spacing:.01em;text-align:center;cursor:pointer} +.custom-item-btn:active{background:#f4f4f4} +/* Ties a custom line back to the "not stock" meaning at a glance, and + stops anyone mistaking it for a catalogue item on a stocktake. */ +.custom-line .nm{color:#8a6d3b} +.custom-line .nm small{color:#a08a5e} + /* ---- Discount block ---- */ /* The trigger rides INSIDE the Subtotal row (see the markup below), so it adds no vertical height at all — the footer keeps every pixel it had @@ -292,6 +303,10 @@ input{font:inherit;color:inherit} :root[data-theme="dark"] .totals b{color:var(--fg)} :root[data-theme="dark"] .totals.grand{border-top-color:var(--muted);color:var(--fg)} :root[data-theme="dark"] .discount-trigger{color:#888} +:root[data-theme="dark"] .custom-item-btn{border-color:#3a3a3a;color:#999} +:root[data-theme="dark"] .custom-item-btn:active{background:#222} +:root[data-theme="dark"] .custom-line .nm{color:#d2b47c} +:root[data-theme="dark"] .custom-line .nm small{color:#a08a5e} :root[data-theme="dark"] .discount-trigger:active{color:var(--fg)} :root[data-theme="dark"] .discount-trigger.has-value{color:#ff6680} :root[data-theme="dark"] .discount-pills{background:#222;border-color:var(--muted)} @@ -410,13 +425,20 @@ input{font:inherit;color:inherit}
+ +
- Subtotal + Subtotal (ex VAT)
-
VAT 15%R 0.00
+
VAT 15%R 0.00
+ +
+ + diff --git a/public/pos/receipt.html b/public/pos/receipt.html index b52dbf1..7358102 100644 --- a/public/pos/receipt.html +++ b/public/pos/receipt.html @@ -376,8 +376,15 @@ const sale = refund createdAt: refund.when, payment: refund.originalPayment || 'cash', total: Number(refund.refundAmount) || 0, - sub: Math.round((Number(refund.refundAmount)||0) / 1.15 * 100) / 100, - vat: Math.round(((Number(refund.refundAmount)||0) - (Number(refund.refundAmount)||0)/1.15) * 100) / 100, + // The credit is the VAT-inclusive total the customer got back; + // split it on the rate the sale was rung up at so the CREDIT + // slip reconciles line-for-line against the original receipt. + vatPct: (() => { const p = Number(refund.vat_percent); + return Number.isFinite(p) && p >= 0 && p <= 100 ? p : 15; })(), + sub: (() => { const t = Number(refund.refundAmount)||0, p = Number(refund.vat_percent) || 15; + return Math.round(t / (1 + p/100) * 100) / 100; })(), + vat: (() => { const t = Number(refund.refundAmount)||0, p = Number(refund.vat_percent) || 15; + const n = t / (1 + p/100); return Math.round((t - n) * 100) / 100; })(), items: (refund.items||[]).map(it => ({ sku: it.sku, description: it.description, qty: Number(it.qty)||0, selling_price: Number(it.selling_price)||0, @@ -429,8 +436,14 @@ function renderReceipt(s){ // values; the gross_* are pre-discount. The receipt surfaces // both when a discount was applied, so the customer can see what // they saved. + // The VAT rate is stored on the sale, so a slip re-printed months + // later still labels its VAT line with the rate actually charged + // rather than today's default. + const rawPct = Number(s.vatPct != null ? s.vatPct : (s.vat_percent != null ? s.vat_percent : 15)); + const vatPct = Number.isFinite(rawPct) && rawPct >= 0 && rawPct <= 100 ? rawPct : 15; + const vatRateLabel = vatPct > 0 ? `VAT ${vatPct}%` : 'VAT'; const sub = s.sub != null ? s.sub : (s.total - (s.vat||0)); - const vat = s.vat != null ? s.vat : (s.total * 15/115); + const vat = s.vat != null ? s.vat : (s.total * vatPct / (100 + vatPct)); const paid = s.payment==='cash' ? fmt(s.cashGiven||0) : fmt(s.total); const d = s.discount || { kind: 'none', input: 0, amount: 0 }; const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0; @@ -438,8 +451,8 @@ function renderReceipt(s){ // fields existed, fall back to deriving the gross from the // line items if the till didn't stash it. const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0); - const grossSub = gross / 1.15; - const grossVat = gross - grossSub; + const grossSub = gross; + const grossVat = (gross * vatPct) / 100; // The POS letterhead record exposes `display_name`, not `name` — // reading `.name` meant this always fell back to 'WFSALES POS'. @@ -479,7 +492,7 @@ function renderReceipt(s){ // discount totals so it's obvious what was knocked off. const grossBlock = hasDiscount ? `
Subtotal (gross)${fmt(grossSub)}
-
VAT 15% (gross)${fmt(grossVat)}
+
${esc(vatRateLabel)} (gross)${fmt(grossVat)}
Total (gross)${fmt(gross)}
Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}− ${fmt(d.amount)}
` : ''; @@ -503,7 +516,7 @@ function renderReceipt(s){
${s.refund ? '' : grossBlock}
Subtotal${fmt(sub)}
-
VAT 15%${fmt(vat)}
+
${esc(vatRateLabel)}${fmt(vat)}
${s.refund ? 'CREDIT' : 'Total'}${fmt(s.total)}
${s.refund ? `
${esc(method)} Refunded${fmt(s.total)}
@@ -681,8 +694,14 @@ async function printThermalBluetooth(){ function saleEscPosRenderers(s){ const E = (n) => String.fromCharCode(n); const ESC = E(0x1B), GS = E(0x1D); + // The VAT rate is stored on the sale, so a slip re-printed months + // later still labels its VAT line with the rate actually charged + // rather than today's default. + const rawPct = Number(s.vatPct != null ? s.vatPct : (s.vat_percent != null ? s.vat_percent : 15)); + const vatPct = Number.isFinite(rawPct) && rawPct >= 0 && rawPct <= 100 ? rawPct : 15; + const vatRateLabel = vatPct > 0 ? `VAT ${vatPct}%` : 'VAT'; const sub = s.sub != null ? s.sub : (s.total - (s.vat||0)); - const vat = s.vat != null ? s.vat : (s.total * 15/115); + const vat = s.vat != null ? s.vat : (s.total * vatPct / (100 + vatPct)); const discount = s.discount || { kind: 'none', input: 0, amount: 0 }; const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0; return { @@ -726,15 +745,15 @@ function saleEscPosRenderers(s){ let out = '--------------------------------\n'; if (hasDiscount) { const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0); - const grossSub = gross / 1.15; - const grossVat = gross - grossSub; + const grossSub = gross; + const grossVat = (gross * vatPct) / 100; out += `Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`; - out += `VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`; + out += `${vatRateLabel} (gross)${' '.repeat(Math.max(1,32-vatRateLabel.length-8))}R ${grossVat.toFixed(2)}\n`; out += `Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`; out += `Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`; } - out += `Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`; - out += `VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`; + out += `Subtotal (ex VAT)${' '.repeat(Math.max(1,32-17))}R ${sub.toFixed(2)}\n`; + out += `${vatRateLabel}${' '.repeat(Math.max(1,32-vatRateLabel.length))}R ${vat.toFixed(2)}\n`; out += ESC+'!'+E(0x08) + `${s.refund?'CREDIT':'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00); return out; }, diff --git a/src/app/api/v1/pos/checkout/route.ts b/src/app/api/v1/pos/checkout/route.ts index 542b9bd..965c155 100644 --- a/src/app/api/v1/pos/checkout/route.ts +++ b/src/app/api/v1/pos/checkout/route.ts @@ -12,10 +12,18 @@ import { /** * POST /api/v1/pos/checkout * - * Confirms a POS sale. Body: { items: [{stock_item_id, qty, unit_price, - * sku?, description?}], payment_method: "cash"|"card"|"eft"|"account", - * cash_given?, customer_id?, client_ref?, - * discount_kind?: "amount"|"percent"|"none", discount_input?: number }. + * Confirms a POS sale. Body: { items: [{stock_item_id?, qty, unit_price, + * sku?, description?, is_custom?}], payment_method: "cash"|"card"|"eft"|"account", + * cash_given?, customer_id?, client_ref?, letterhead_id?, + * discount_kind?: "percent"|"none", discount_input?: number }. + * + * Prices are EX-VAT (the catalogue stores them that way); VAT is + * added on top at commit time using the rate on `letterhead_id`'s + * letterhead, so an R100 line rings up as R115 at 15%. + * + * A line with `is_custom: true` has no `stock_item_id` and no + * catalogue row — the cashier's own wording and price, recorded on + * the sale and the slip but never touching stock. * * `client_ref` is the till's local sale id. Passing it makes checkout * idempotent, so the offline queue can safely retry a sale whose @@ -53,9 +61,9 @@ export async function POST(req: NextRequest) { ? (body.payment_method as PosPaymentMethod) : "cash"; - // Validate the optional discount. Anything missing or weird - // collapses to "no discount" — better than rejecting the sale - // over a field the cashier didn't even know they sent. + // Validate the optional discount. Anything missing, or a + // percentage outside the fixed 20 / 40 tiers, collapses to "no + // discount" — the service re-derives the allowed value anyway. const discountKind: PosDiscountKind = POS_DISCOUNT_KIND_VALUES.includes( body?.discount_kind, ) @@ -73,6 +81,9 @@ export async function POST(req: NextRequest) { cash_given: body?.cash_given, customer_id: body?.customer_id, client_ref: body?.client_ref, + // The till names which shop it's running under; the service reads + // the VAT rate off that letterhead rather than trusting the wire. + letterhead_id: body?.letterhead_id, discount_kind: discountKind, discount_input: discountInput, }); diff --git a/src/app/api/v1/pos/sales/[id]/refund/route.ts b/src/app/api/v1/pos/sales/[id]/refund/route.ts index 85bfc55..30359f5 100644 --- a/src/app/api/v1/pos/sales/[id]/refund/route.ts +++ b/src/app/api/v1/pos/sales/[id]/refund/route.ts @@ -66,6 +66,9 @@ export async function POST( sale: result.sale, items: result.items, refundAmount: result.refundAmount, + // The rate the original sale was charged at, so the CREDIT slip + // splits the refund into the same net/VAT the receipt showed. + vat_percent: result.sale.vat_percent ?? null, // Straight off the sale, never off the request. tender: result.sale.payment_method, stockErrors: result.stockErrors, diff --git a/src/lib/pos-cart-quantity.test.ts b/src/lib/pos-cart-quantity.test.ts index 36a2642..dfa5bd9 100644 --- a/src/lib/pos-cart-quantity.test.ts +++ b/src/lib/pos-cart-quantity.test.ts @@ -87,7 +87,20 @@ describe("POS discount trigger costs no footer height", () => { // (12px padding + a 42px min-height button). The trigger now rides // inside the existing Subtotal row, so it adds nothing vertically. expect(POS_HTML).not.toMatch(/class="discount"/); - expect(POS_HTML).toMatch(/
\s*Subtotal\s*