feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
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.
This commit is contained in:
parent
ad4ef77bfd
commit
8232a87f99
10 changed files with 811 additions and 301 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=>({'&':'&','<
|
|||
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(){
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue