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