merge: POS ex-VAT totals, custom lines and fixed discount tiers

This commit is contained in:
Dave 2026-10-12 22:54:12 +02:00
commit e230d17f0c
10 changed files with 811 additions and 301 deletions

View file

@ -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,

View file

@ -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 `<div class="line" data-od-id="line-${p.id}" data-id="${p.id}">
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
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 `<div class="line${custom?' custom-line':''}" data-od-id="line-${p.id}" data-id="${p.id}">
<div class="nm">${escapeHTML(p.description||p.name||p.sku)}<small>${sub}</small></div>
<div class="qty">
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease quantity">−</button>
<button class="v" data-od-id="qty-set" onclick="window.__pos.openEditLine('${p.id}')" aria-label="Set quantity for ${escapeAttr(p.description||p.sku||'item')}">${qty}</button>
@ -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=>({'&':'&amp;','<
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'&#96;'); }
// 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(){

View file

@ -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}
</div>
<div class="sheet-body" id="cartLines"></div>
<div class="sheet-foot">
<!-- Ad-hoc line: anything the customer asks for that isn't a
catalogue item (a cut bolt, a measurement) gets typed in
here at whatever price was agreed. It rings up and prints
like any other line but never touches stock. -->
<button class="custom-item-btn" data-od-id="add-custom-item" onclick="openCustomItem()">
+ Add a custom item
</button>
<!-- The discount trigger rides inside the Subtotal row so the footer
costs no extra height. Tapping it expands the R / % pills, number
input and Apply button below; the discounted amount is then shown
in red on its own totals line, and the server re-derives the
resolved rand value at commit time. -->
<div class="totals">
<span>Subtotal
<span>Subtotal (ex VAT)
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
<span id="discountTriggerLabel">Add discount</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
@ -424,19 +446,14 @@ input{font:inherit;color:inherit}
</span>
<b id="subTotal">R 0.00</b>
</div>
<div class="totals"><span>VAT 15%</span><b id="vatTotal">R 0.00</b></div>
<div class="totals"><span id="vatLabel">VAT 15%</span><b id="vatTotal">R 0.00</b></div>
<div class="discount-body" id="discountBody" hidden>
<div class="discount-pills" role="tablist">
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
<div class="discount-pills" role="group" aria-label="Discount tier">
<button class="discount-pill" id="discTier20" data-od-id="discount-20" onclick="setDiscountTier(20)">20% off</button>
<button class="discount-pill" id="discTier40" data-od-id="discount-40" onclick="setDiscountTier(40)">40% off</button>
</div>
<div class="discount-input-row">
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
<span class="discount-suffix" id="discSuffix">R</span>
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
</div>
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
<p class="discount-hint" id="discHint">Tap a tier to apply it. Tap it again to clear.</p>
</div>
<div class="totals discount-line" id="discountLine" hidden>
@ -525,6 +542,25 @@ input{font:inherit;color:inherit}
</div>
</div>
<!-- Custom (ad-hoc) item composer -->
<div class="edit-backdrop" id="customBackdrop" onclick="closeCustomItem()"></div>
<div class="edit-sheet" id="customSheet" role="dialog" aria-labelledby="customTitle" aria-modal="true">
<h3 id="customTitle">Custom item</h3>
<p class="discount-hint">Anything that isn't stock — e.g. "Bolt cutting". It rings up at the price you type, and doesn't come out of stock.</p>
<div class="edit-row">
<label for="customDesc">Description</label>
<input id="customDesc" type="text" autocomplete="off" placeholder="Bolt cutting" data-od-id="custom-desc">
</div>
<div class="edit-row">
<label for="customPrice">Price (ex VAT)</label>
<input id="customPrice" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00" data-od-id="custom-price">
</div>
<div class="edit-actions">
<button class="secondary" data-od-id="custom-cancel" onclick="closeCustomItem()">Cancel</button>
<button class="primary" data-od-id="custom-save" onclick="saveCustomItem()">Add to cart</button>
</div>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>

View file

@ -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 ? `
<div class="r-row"><span>Subtotal (gross)</span><span class="b">${fmt(grossSub)}</span></div>
<div class="r-row"><span>VAT 15% (gross)</span><span class="b">${fmt(grossVat)}</span></div>
<div class="r-row"><span>${esc(vatRateLabel)} (gross)</span><span class="b">${fmt(grossVat)}</span></div>
<div class="r-row"><span>Total (gross)</span><span class="b">${fmt(gross)}</span></div>
<div class="r-row" style="color:var(--ok)"><span>Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}</span><span class="b">− ${fmt(d.amount)}</span></div>
` : '';
@ -503,7 +516,7 @@ function renderReceipt(s){
<div class="r-totals">
${s.refund ? '' : grossBlock}
<div class="r-row"><span>Subtotal</span><span class="b">${fmt(sub)}</span></div>
<div class="r-row"><span>VAT 15%</span><span class="b">${fmt(vat)}</span></div>
<div class="r-row"><span>${esc(vatRateLabel)}</span><span class="b">${fmt(vat)}</span></div>
<div class="r-row grand"><span>${s.refund ? 'CREDIT' : 'Total'}</span><span class="b">${fmt(s.total)}</span></div>
${s.refund
? `<div class="r-row" style="margin-top:6px"><span><span class="pill">${esc(method)}</span> Refunded</span><span class="b">${fmt(s.total)}</span></div>
@ -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;
},

View file

@ -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,
});

View file

@ -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,

View file

@ -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(/<div class="totals">\s*<span>Subtotal\s*<button class="discount-trigger"/);
expect(POS_HTML).toMatch(
/<div class="totals">\s*<span>Subtotal[^<]*\s*<button class="discount-trigger"/,
);
});
it("offers exactly the two fixed discount tiers, with no free-text input", () => {
// The tiers replaced the old R-off / %-off pills + number field.
// A free-typed discount is what we're deliberately removing, so its
// absence is the thing worth pinning — an input creeping back in
// would let a cashier invent an arbitrary discount again.
expect(POS_HTML).toMatch(/id="discTier20"[^>]*onclick="setDiscountTier\(20\)"/);
expect(POS_HTML).toMatch(/id="discTier40"[^>]*onclick="setDiscountTier\(40\)"/);
expect(POS_HTML).not.toMatch(/id="discInput"/);
expect(POS_HTML).not.toMatch(/setDiscountKind|applyDiscount/);
});
it("keeps every id the discount JS depends on", () => {

View file

@ -69,6 +69,22 @@ function fakePb(): PosPb & {
update: async () => ({}),
};
}
// Letterheads carry the VAT rate. One at 0% proves the rate is
// read from the letterhead and not hardcoded to 15; any other
// id 404s, which is how the "letterhead was deleted" fallback
// gets exercised.
if (name === "letterhead_settings") {
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
getOne: async (id: string) => {
if (id === "lh-zero") return { id, vat_percent: 0 };
throw new Error("404");
},
create: salesCreate,
update: async () => ({}),
};
}
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
@ -83,147 +99,180 @@ function fakePb(): PosPb & {
return Object.assign(pb as PosPb, { _salesCalls, _stockItem: stockItem });
}
describe("checkoutSale — discount math", () => {
let pb: ReturnType<typeof fakePb>;
// One fresh stub per test so `_salesCalls` (which backs `lastSale`)
// never leaks between cases.
let pb: ReturnType<typeof fakePb>;
beforeEach(() => {
pb = fakePb();
});
beforeEach(() => {
pb = fakePb();
/** The pos_sales row written by the most recent checkout. */
function lastSale(p: ReturnType<typeof fakePb>): Record<string, any> {
return p._salesCalls[p._salesCalls.length - 1] as Record<string, any>;
}
describe("checkoutSale — ex-VAT totals", () => {
// Catalogue prices are EX-VAT (selling_price = cost × markup, and
// cost is the supplier's ex-VAT figure). VAT is added ON TOP, not
// backed out of the shelf price. R100 ex-VAT rings up as R115.
it("adds VAT on top of an ex-VAT line", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.subtotal).toBeCloseTo(100, 5);
expect(saleData.vat).toBeCloseTo(15, 5);
expect(saleData.total).toBeCloseTo(115, 5);
});
function lastSale(pb: ReturnType<typeof fakePb>) {
const calls = pb._salesCalls;
return calls[calls.length - 1] as Record<string, unknown>;
}
it("records no discount by default (kind=none, amount=0, input=0)", async () => {
it("records the pre-discount gross on the ex-VAT basis", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
items: [{ stock_item_id: "si-1", qty: 2, unit_price: 50 }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_subtotal).toBeCloseTo(100, 5);
expect(saleData.gross_vat).toBeCloseTo(15, 5);
expect(saleData.gross_total).toBeCloseTo(115, 5);
});
it("stores the VAT rate it charged, so history survives a rate change", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(lastSale(pb).vat_percent).toBe(15);
});
it("reads the rate from the till's letterhead rather than the wire", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
letterhead_id: "lh-zero",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.vat_percent).toBe(0);
expect(saleData.vat).toBe(0);
expect(saleData.total).toBeCloseTo(100, 5);
});
it("falls back to the standard rate when the letterhead is gone", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
letterhead_id: "lh-deleted",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(lastSale(pb).vat_percent).toBe(15);
});
it("adjusts cash change against the VAT-inclusive total", async () => {
// R100 ex-VAT → R115 total. Cash R120 → change R5.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
cash_given: 120,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.total).toBeCloseTo(115, 5);
expect(saleData.change_given).toBeCloseTo(5, 5);
});
});
describe("checkoutSale — discount tiers", () => {
// Only two discounts exist: 20% and 40%. Anything else is refused
// rather than coerced, so a stale till can't invent a discount.
it("applies the 20% tier off the ex-VAT subtotal", async () => {
// R200 ex-VAT → R160 net → vat 24 → total 184.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 20,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_kind).toBe("percent");
expect(saleData.discount_input).toBe(20);
expect(saleData.discount_amount).toBeCloseTo(40, 5);
expect(saleData.subtotal).toBeCloseTo(160, 5);
expect(saleData.vat).toBeCloseTo(24, 5);
expect(saleData.total).toBeCloseTo(184, 5);
});
it("applies the 40% tier", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 40,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBeCloseTo(40, 5);
expect(saleData.subtotal).toBeCloseTo(60, 5);
expect(saleData.total).toBeCloseTo(69, 5);
});
it("keeps the pre-discount gross for the audit trail", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 20,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_subtotal).toBeCloseTo(200, 5);
expect(saleData.gross_vat).toBeCloseTo(30, 5);
expect(saleData.gross_total).toBeCloseTo(230, 5);
expect(saleData.total).toBeCloseTo(184, 5);
});
it("refuses a percentage outside the allowed tiers", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 90,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_kind).toBe("none");
expect(saleData.discount_amount).toBe(0);
expect(saleData.discount_input).toBe(0);
expect(saleData.gross_total).toBe(115);
expect(saleData.total).toBe(115);
expect(saleData.total).toBeCloseTo(230, 5);
});
it("applies an R-amount discount to the post-VAT total and splits VAT proportionally", async () => {
// 1 × R115 = R115 gross. R15 off → R100 net.
// sub = 100 / 1.15 ≈ 86.96
// vat = 100 - 86.96 ≈ 13.04
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 15,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(115);
expect(saleData.discount_kind).toBe("amount");
expect(saleData.discount_input).toBe(15);
expect(saleData.discount_amount).toBe(15);
expect(saleData.total).toBe(100);
expect(saleData.subtotal).toBeCloseTo(86.9565, 3);
expect(saleData.vat).toBeCloseTo(13.0435, 3);
});
it("applies a percentage discount", async () => {
// 1 × R200 = R200 gross. 10% off → R180 net.
it("refuses the retired rand-amount discount", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 10,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(200);
expect(saleData.discount_amount).toBe(20);
expect(saleData.discount_input).toBe(10);
expect(saleData.total).toBe(180);
});
it("caps a fat-finger percentage at the gross total (no negative total)", async () => {
// 1 × R100, 200% off — should be capped at R100 off, not -R100.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 200,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBe(100);
expect(saleData.total).toBe(0);
});
it("caps a fat-finger amount at the gross total", async () => {
// 1 × R50, R999 off — should be capped at R50 off.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 50 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 999,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBe(50);
expect(saleData.total).toBe(0);
});
it("ignores a discount of 0 (no change to totals, no row-level discount state)", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 0,
discount_input: 50,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_kind).toBe("none");
expect(saleData.discount_amount).toBe(0);
expect(saleData.total).toBe(115);
});
it("gross_subtotal and gross_vat are recorded alongside the post-discount values", async () => {
// The accountant needs both views on the audit row.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 15,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(115);
expect(saleData.gross_subtotal).toBeCloseTo(100, 2);
expect(saleData.gross_vat).toBeCloseTo(15, 2);
expect(saleData.total).toBe(100);
});
it("adjusts cash change against the post-discount total", async () => {
// R200 - 10% = R180. Cash given R200 → change R20.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
cash_given: 200,
discount_kind: "percent",
discount_input: 10,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.change_given).toBe(20);
expect(saleData.total).toBe(180);
});
});
@ -463,3 +512,170 @@ describe("checkoutSale — account delivery note", () => {
expect(result.deliveryNoteError).toContain("no customer");
});
});
describe("checkoutSale — custom (ad-hoc) lines", () => {
// A line the cashier typed at the counter ("Bolt cutting" @ R20).
// It records on the sale and prints on the slip, but has no
// catalogue row so it must not touch stock in any way.
it("accepts a custom line with no stock_item_id", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items[0].description).toBe("Bolt cutting");
expect(result.items[0].is_custom).toBe(true);
});
it("stores an empty stock_item relation, not a bogus id", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items[0].stock_item).toBe("");
});
it("takes no cost of goods — it is a service, not stock", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items[0].unit_cost).toBe(0);
});
it("writes NO stock_ledger row for a custom line", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
// ledgerCreate pushes nothing into _salesCalls, so a clean array
// after the sale row means only the pos_sales create ran.
expect(pb._salesCalls).toHaveLength(1);
expect(pb._salesCalls[0].sale_number).toBeDefined();
});
it("does NOT decrement stock for a custom line", async () => {
const before = { ...pb._stockItem };
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 5, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
expect(pb._stockItem.current_qty).toBe(before.current_qty);
});
it("still charges VAT on a custom line", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const s = lastSale(pb);
expect(s.subtotal).toBeCloseTo(20, 5);
expect(s.vat).toBeCloseTo(3, 5);
expect(s.total).toBeCloseTo(23, 5);
});
it("still discounts a custom line", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 20,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(lastSale(pb).total).toBeCloseTo(18.4, 5);
});
it("mixes a custom line with catalogue lines in one sale", async () => {
const result = await checkoutSale(pb, {
items: [
{ stock_item_id: "si-1", qty: 1, unit_price: 100 },
{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" },
],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items).toHaveLength(2);
expect(result.items.filter((i) => i.is_custom)).toHaveLength(1);
// R120 ex-VAT → R138 total.
expect(lastSale(pb).total).toBeCloseTo(138, 5);
});
it("rejects a custom line with no description — it would print blank", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: " " }],
payment_method: "cash",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_line");
});
it("rejects a custom line with a non-positive qty", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 0, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_line");
});
it("still rejects a catalogue line with no stock_item_id", async () => {
// The is_custom escape hatch must not become a way to sell
// arbitrary catalogue lines without a stock id.
const result = await checkoutSale(pb, {
items: [{ qty: 1, unit_price: 100, description: "Sneaky" }],
payment_method: "cash",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_line");
});
it("refuses a custom line on an account sale", async () => {
// An account sale raises a signed delivery note and feeds the
// customer's statement, so an ad-hoc charge must not slip onto
// one. The till can't be trusted to have prevented it.
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "account",
customer_id: "cu-1",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_line");
expect(result.message).toMatch(/account sale/i);
});
it("allows a custom line on card and EFT", async () => {
for (const method of ["card", "eft"] as const) {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: method,
});
expect(result.ok, `${method} should accept a custom line`).toBe(true);
}
});
it("reports no stock errors for a custom-only sale", async () => {
const result = await checkoutSale(pb, {
items: [{ is_custom: true, qty: 1, unit_price: 20, description: "Bolt cutting" }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.stockErrors).toHaveLength(0);
});
});

View file

@ -6,6 +6,7 @@ import type {
PosSaleItem,
StockItem,
} from "@/lib/types";
import { isAllowedDiscountPercent } from "@/lib/types";
import {
createAdHocDeliveryNote,
type AdHocDeliveryNoteInput,
@ -186,11 +187,26 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
// ─── checkoutSale ──────────────────────────────────────────────────
export type PosCheckoutLineInput = {
stock_item_id: string;
/**
* Empty for a custom line. Required (and non-empty) for a
* catalogue line — see `is_custom`.
*/
stock_item_id?: string;
qty: number;
/**
* EX-VAT unit price. The catalogue stores prices ex-VAT, so this
* is a net figure and VAT is added on top at commit time.
*/
unit_price: number;
sku?: string;
description?: string;
/**
* A line the cashier typed by hand ("Bolt cutting" @ R20) with
* no catalogue row behind it. Custom lines are recorded on the
* sale and print on the slip, but never touch stock: no ledger
* row, no `current_qty` decrement, no `stock_items` row.
*/
is_custom?: boolean;
};
export type PosCheckoutInput = {
@ -200,6 +216,13 @@ export type PosCheckoutInput = {
customer_id?: string;
/** The till's local sale id, used as an idempotency key. */
client_ref?: string;
/**
* Which letterhead this till is running under. The server reads
* `vat_percent` from it rather than trusting a rate off the wire,
* so a stale or tampered till can't book the wrong VAT rate.
* Unknown / omitted falls back to DEFAULT_VAT_PERCENT.
*/
letterhead_id?: string;
/**
* Optional discount applied to the sale. Off-the-total (post-VAT)
* — the cashier's view of "I took R100 off" is the same on the
@ -215,6 +238,34 @@ export type PosCheckoutInput = {
discount_input?: number;
};
/**
* VAT rate applied when the till names no letterhead (or names one
* that has since been deleted). South Africa's standard rate.
*/
export const DEFAULT_VAT_PERCENT = 15;
/**
* Resolves the VAT rate for a sale from the till's letterhead.
*
* The rate is read from PocketBase rather than accepted off the
* wire: the letterhead is the business record of "this shop charges
* VAT at N%", and the till is an untrusted client holding a shared
* API key. A missing letterhead degrades to the standard rate
* rather than failing the sale — a till on a flaky network must
* still be able to take money.
*/
async function resolveVatPercent(pb: PosPb, letterheadId?: string): Promise<number> {
const id = (letterheadId || "").trim();
if (!id) return DEFAULT_VAT_PERCENT;
try {
const row = await pb.collection("letterhead_settings").getOne<{ vat_percent?: number }>(id);
const pct = Number(row.vat_percent);
return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : DEFAULT_VAT_PERCENT;
} catch {
return DEFAULT_VAT_PERCENT;
}
}
export type PosCheckoutResult =
| {
ok: true;
@ -461,13 +512,45 @@ export async function checkoutSale(
return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
}
for (const line of input.items) {
if (!line.stock_item_id || !Number.isFinite(line.qty) || line.qty <= 0) {
if (!Number.isFinite(line.qty) || line.qty <= 0) {
return {
ok: false,
reason: "invalid_line",
message: `each item needs a stock_item_id and a positive qty (got ${JSON.stringify(line)})`,
message: `each item needs a positive qty (got ${JSON.stringify(line)})`,
};
}
// A custom line is the cashier's own wording and price — no
// catalogue row, so no stock_item_id to check. It does need
// something to print on the slip.
if (line.is_custom) {
if (!(line.description || "").trim()) {
return {
ok: false,
reason: "invalid_line",
message: `a custom line needs a description (got ${JSON.stringify(line)})`,
};
}
} else if (!line.stock_item_id) {
return {
ok: false,
reason: "invalid_line",
message: `each catalogue item needs a stock_item_id (got ${JSON.stringify(line)})`,
};
}
}
// Custom lines are counter-only: cash / card / EFT. An account sale
// raises a delivery note the customer signs for and it feeds their
// statement, so an ad-hoc "bolt cutting" charge on one is a
// paperwork problem discovered too late to fix cleanly. Refuse it
// at commit time rather than letting it slip onto a DN.
if (input.payment_method === "account" && input.items.some((l) => l.is_custom)) {
return {
ok: false,
reason: "invalid_line",
message:
"A custom item can't go on an account sale — it raises a delivery note the customer signs for. Take it as cash, card or EFT instead.",
};
}
// 0. Idempotency replay — before any stock check, so a retry of an
@ -515,6 +598,10 @@ export async function checkoutSale(
// line is the problem instead.
const stockItems = new Map<string, StockItem>();
for (const line of input.items) {
// Custom lines have no catalogue row to fetch. Skipping the
// lookup is the whole point — this is what lets a till sell
// something that was never a stock item.
if (line.is_custom || !line.stock_item_id) continue;
let item: StockItem;
try {
item = (await pb.collection("stock_items").getOne(line.stock_item_id)) as StockItem;
@ -529,36 +616,41 @@ export async function checkoutSale(
stockItems.set(line.stock_item_id, item);
}
// 2. Totals — VAT-inclusive shelf pricing (matches the existing POS
// cart's own calc: sub = total / 1.15, vat = total - sub).
// 2. Totals — EX-VAT basis.
//
// The discount is applied to the gross total (post-VAT — the
// cashier's view of "I took R100 off the bill"). The gross
// values are recorded alongside the post-discount values so the
// accountant can still see what the till would have rung up
// before the discount was applied.
const grossTotal = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0);
const grossSubtotal = grossTotal / 1.15;
const grossVat = grossTotal - grossSubtotal;
// Catalogue prices are stored ex-VAT: `selling_price` is derived
// as `cost_price × markup` from the supplier's ex-VAT cost, with
// no VAT uplift anywhere in the derivation. So a line's
// unit_price is a NET figure and VAT is added on top, rather
// than being backed out of a tax-inclusive shelf price.
//
// An R100 ex-VAT line rings up as R115: subtotal R100, VAT R15.
//
// The discount is applied to the ex-VAT gross subtotal, so a
// "20% off" is a true 20% off both the net and the VAT on it —
// which is what the customer expects and what keeps the books
// internally consistent. Discounts are restricted to the fixed
// DISCOUNT_PERCENT_OPTIONS tiers; anything else is refused rather
// than coerced, so a stale till can't invent a discount.
const vatPercent = await resolveVatPercent(pb, input.letterhead_id);
const grossSubtotal = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0);
const grossVat = (grossSubtotal * vatPercent) / 100;
const grossTotal = grossSubtotal + grossVat;
const discountKind: PosDiscountKind =
input.discount_kind === "percent" && isAllowedDiscountPercent(Number(input.discount_input))
? "percent"
: "none";
const discountInput = discountKind === "percent" ? Number(input.discount_input) : 0;
// Capped at the ex-VAT gross subtotal — the base the discount
// actually lands on — so the post-discount total can't go negative.
const discountAmount =
discountKind === "percent" ? Math.min((grossSubtotal * discountInput) / 100, grossSubtotal) : 0;
const subtotal = Math.max(0, grossSubtotal - discountAmount);
const vat = (subtotal * vatPercent) / 100;
const total = subtotal + vat;
// Resolve the discount. Cap at the gross total so a fat-finger
// percentage can't take the post-discount total below zero.
// `discount_input` is what the operator typed (10 for "10%" or
// 50 for "R50"); `discount_amount` is the resolved rand value
// we actually subtract.
const discountKind: PosDiscountKind = input.discount_kind ?? "none";
const discountInput = Number(input.discount_input ?? 0) || 0;
let discountAmount = 0;
if (discountKind === "amount" && discountInput > 0) {
discountAmount = Math.min(discountInput, grossTotal);
} else if (discountKind === "percent" && discountInput > 0) {
discountAmount = Math.min((grossTotal * discountInput) / 100, grossTotal);
}
// The receipt / till show the post-discount numbers; the
// accountant can compare to gross_* on the same row.
const total = Math.max(0, grossTotal - discountAmount);
const subtotal = total / 1.15;
const vat = total - subtotal;
const isCash = input.payment_method === "cash";
const cashGiven = isCash ? Number(input.cash_given ?? 0) : 0;
const changeGiven = isCash ? Math.max(0, cashGiven - total) : 0;
@ -580,6 +672,7 @@ export async function checkoutSale(
subtotal,
vat,
total,
vat_percent: vatPercent,
customer: input.customer_id || "",
client_ref: clientRef,
})) as PosSale;
@ -595,15 +688,25 @@ export async function checkoutSale(
try {
const saleItem = (await pb.collection("pos_sale_items").create({
sale: sale.id,
stock_item: line.stock_item_id,
// Empty relation for a custom line — PB allows it now that
// `stock_item` is not required, and `is_custom` is what
// marks the line for reporting.
stock_item: line.stock_item_id || "",
is_custom: !!line.is_custom,
sku: line.sku || "",
description: line.description || "",
qty: line.qty,
unit_cost: stockItems.get(line.stock_item_id)?.cost_price ?? 0,
unit_cost: stockItems.get(line.stock_item_id ?? "")?.cost_price ?? 0,
unit_price: line.unit_price,
})) as PosSaleItem;
items.push(saleItem);
// A custom line is a service rung up at the counter — there is
// nothing in the cupboard to decrement, so no ledger row and
// no `current_qty` write. The sale row above is the whole
// record of it.
if (line.is_custom || !line.stock_item_id) continue;
await pb.collection("stock_ledger").create({
stock_item: line.stock_item_id,
change_qty: -line.qty,
@ -619,7 +722,7 @@ export async function checkoutSale(
await pb.collection("stock_items").update(line.stock_item_id, { current_qty: nextQty });
} catch (err) {
stockErrors.push({
stock_item_id: line.stock_item_id,
stock_item_id: line.stock_item_id || "",
error: err instanceof Error ? err.message : String(err),
});
}

View file

@ -1684,18 +1684,27 @@ export interface PosSale extends BaseRecord {
cash_given: number;
change_given: number;
/**
* The "gross" totals BEFORE any discount — the sum of the line
* items' (unit_price × qty) at their sold prices.
* The "gross" totals BEFORE any discount, on the EX-VAT basis
* (see `vat_percent`). `gross_subtotal` is the sum of the line
* items' (unit_price × qty) — catalogue prices are stored
* ex-VAT, so this is a net figure. `gross_vat` is the VAT on
* that net, and `gross_total` is what the customer would have
* paid pre-discount.
*/
gross_subtotal: number;
gross_vat: number;
gross_total: number;
/**
* Discount applied to the sale (post-VAT, off the gross total).
* `discount_kind` records whether the operator typed a rand
* amount or a percentage. `discount_amount` is the *resolved*
* rand value of the discount (always in R, never %), capped at
* the gross total so a fat-finger % can't go negative.
* Discount applied to the sale. Percentage-only and fixed-choice:
* the till offers exactly DISCOUNT_PERCENT_OPTIONS (20 / 40),
* never a free-typed amount, so a fat-finger can't produce an
* arbitrary discount. `discount_amount` is the *resolved* rand
* value taken off the EX-VAT gross subtotal, capped at that
* subtotal so the total can never go negative.
*
* Because the discount lands on the ex-VAT base, VAT is charged
* on the discounted net — a "20% off" is a true 20% off both
* net and VAT.
*
* The displayed `subtotal` / `vat` / `total` on the till and
* receipt are the *post-discount* values; `gross_*` is kept
@ -1704,9 +1713,18 @@ export interface PosSale extends BaseRecord {
discount_amount: number;
discount_kind: PosDiscountKind;
discount_input: number;
/** Ex-VAT net after discount. The receipt's "Subtotal" line. */
subtotal: number;
/** VAT charged on `subtotal` at `vat_percent`. */
vat: number;
/** What the customer pays: `subtotal + vat`. */
total: number;
/**
* The VAT rate this sale was rung up at, resolved server-side
* from the till's letterhead. Stored per sale so a later letterhead
* rate change never rewrites the history of what was charged.
*/
vat_percent: number;
/** customers relation id, or "" for a walk-in with no customer picked. */
customer: string;
/**
@ -1774,13 +1792,52 @@ export const POS_DISCOUNT_KIND_VALUES: readonly PosDiscountKind[] = [
"none",
] as const;
/**
* The only discounts the till offers: 20% off or 40% off.
*
* Percentage-only and fixed-choice on purpose — the previous till
* let the cashier type any percentage or any rand amount, which
* made an unbounded discount one fat-finger away and left the
* "is this deliberate?" question unanswerable after the fact.
* Restricting the choice to two known-good tiers means every
* discount on every slip is self-evidently a real decision.
*
* The server treats this list as the allowlist, not a hint: an
* input outside it is rejected rather than silently coerced, so a
* stale till or a hand-rolled request can't invent a discount.
*/
export const DISCOUNT_PERCENT_OPTIONS: readonly number[] = [20, 40] as const;
/** True when `pct` is one of the discounts the till is allowed to apply. */
export function isAllowedDiscountPercent(pct: number): boolean {
return DISCOUNT_PERCENT_OPTIONS.includes(pct);
}
export interface PosSaleItem extends BaseRecord {
sale: string;
/**
* Empty string for a custom (ad-hoc) line — see `is_custom`.
* Not required at the PB level: a line can exist with no
* catalogue row behind it.
*/
stock_item: string;
/**
* True when the cashier typed the line by hand (e.g. "Bolt
* cutting" at R20) rather than picking it from the catalogue.
* Custom lines carry a description and price only — they have
* no stock movement, no ledger row, and no catalogue entry.
*
* Kept as an explicit flag rather than inferring from a blank
* `stock_item` so "how much did we make on bolt cutting this
* month" is a plain query, and so a future stock-less line type
* can't be confused with a corrupt relation.
*/
is_custom: boolean;
sku: string;
description: string;
qty: number;
unit_cost: number;
/** EX-VAT unit price — the basis the catalogue stores. */
unit_price: number;
}