diff --git a/public/pos/cashup.html b/public/pos/cashup.html
index feda6fc..6216631 100644
--- a/public/pos/cashup.html
+++ b/public/pos/cashup.html
@@ -359,21 +359,36 @@ async function load(){
const sales = j.sales || [];
totals = {cash:0, card:0, eft:0, account:0, count: sales.length};
const counts = {cash:0, card:0, eft:0, account:0};
+ let cashRefunded = 0;
+ let cashRefundCount = 0;
for(const s of sales){
const method = totals[s.payment_method] !== undefined ? s.payment_method : 'cash';
+ // A refunded sale contributes NOTHING to takings — the money went back
+ // out. Leaving it in would report income the shop no longer has.
+ if(s.refunded){
+ if(method === 'cash'){
+ cashRefunded += Number(s.refund_amount || s.total) || 0;
+ cashRefundCount++;
+ }
+ totals.count--;
+ continue;
+ }
totals[method] += Number(s.total)||0;
counts[method]++;
}
grand = totals.cash + totals.card + totals.eft + totals.account;
- // Only cash sales put money in the drawer. Change is already netted
- // out: for a cash sale, cash_given - change_given === total.
- cashSales = totals.cash;
+ // Only cash sales put money in the drawer, and a CASH REFUND takes it
+ // back out again — so expected = cash sales − cash refunded. Change is
+ // already netted out: for a cash sale, cash_given - change_given === total.
+ cashSales = totals.cash - cashRefunded;
document.getElementById('kCount').textContent = totals.count;
document.getElementById('kTotal').textContent = fmt(grand);
document.getElementById('sCashSales').textContent = fmt(cashSales);
document.getElementById('sGrand').textContent = fmt(grand);
paintMethods();
- document.getElementById('mCashN').textContent = counts.cash + ' sales';
+ const cashNote = counts.cash + ' sales'
+ + (cashRefundCount ? ` · ${cashRefundCount} cash refund${cashRefundCount===1?'':'s'} out` : '');
+ document.getElementById('mCashN').textContent = cashNote;
document.getElementById('mCardN').textContent = counts.card + ' sales';
document.getElementById('mEftN').textContent = counts.eft + ' sales';
document.getElementById('mAccN').textContent = counts.account + ' sales';
diff --git a/public/pos/epos-print.js b/public/pos/epos-print.js
index 603bd5e..e115c70 100644
--- a/public/pos/epos-print.js
+++ b/public/pos/epos-print.js
@@ -109,10 +109,17 @@ window.EposPrint = (function () {
const vat = s.vat != null ? s.vat : (s.total * 15 / 115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
+ // A credit slip reuses this whole renderer with `s.refund` set — same
+ // blocks, same printer settings, same ePOS-Print envelope. Only the
+ // wording changes, so there is no second printer path to keep working.
+ const isRefund = !!s.refund;
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
- return '' + xmlEscape(title) + '
';
+ const credit = isRefund
+ ? 'CREDIT NOTE
'
+ : '';
+ return '' + credit + '' + xmlEscape(title) + '
';
},
logo: function () {
return logo ? `${logo.base64}` : '';
@@ -132,8 +139,13 @@ window.EposPrint = (function () {
},
date_slip: function () {
const d = new Date(s.createdAt);
+ // A credit slip is worthless without the sale it reverses, so it
+ // prints that number rather than its own local id.
+ const ref = isRefund
+ ? `Refund of: ${xmlEscape(s.saleNumber || (s.localId || '').slice(-8).toUpperCase())}`
+ : `Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}`;
return `--------------------------------
`
- + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))}
Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}
`;
+ + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))}
${ref}
`;
},
customer: function () {
return s.customer ? `Customer: ${xmlEscape(s.customer.name)}
` : '';
@@ -147,17 +159,26 @@ window.EposPrint = (function () {
},
totals: function () {
const parts = [`--------------------------------
`];
- if (hasDiscount) {
+ // A refund credits back what the customer paid, so the pre-discount
+ // breakdown would be a different number and is not printed.
+ if (hasDiscount && !isRefund) {
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;
parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}
VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}
Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}
Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}
`);
}
parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}
VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}
`);
- parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}
`);
+ parts.push(`${isRefund ? 'CREDIT' : 'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)}
`);
return parts.join('');
},
payment: function () {
+ if (isRefund) {
+ const how = s.payment === 'cash' ? 'CASH'
+ : s.payment === 'account' ? 'ACCOUNT CREDIT'
+ : (s.payment || '').toUpperCase();
+ return `Refunded by: ${xmlEscape(how)}
`
+ + `Goods returned to stock.
`;
+ }
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
let out = `Paid: ${xmlEscape(paidLabel)}
`;
if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}
`;
diff --git a/public/pos/history.html b/public/pos/history.html
index 66c748c..2dcd3a5 100644
--- a/public/pos/history.html
+++ b/public/pos/history.html
@@ -60,6 +60,44 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad
.sale-row .nm{flex:1;min-width:0}
.sale-row .nm b{font-size:14px;font-weight:600;letter-spacing:-.005em;display:block;color:var(--fg)}
.sale-row .nm small{font-size:11px;color:var(--ink-soft);display:block;margin-top:2px;letter-spacing:.01em}
+/* A refunded sale stays in the list — it is the audit trail — but reads as
+ gone: dimmed, struck through the amount, badged. Never colour alone: the
+ word "refunded" is in the row. */
+.sale-row.refunded{opacity:.6}
+.sale-row.refunded .nm b em{font-style:normal;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--danger);border:1px solid currentColor;border-radius:4px;padding:1px 4px;margin-left:4px}
+.sale-row.refunded .pr{text-decoration:line-through}
+.line-holding{background:var(--surface);transition:background .15s}
+
+/* ---- Refund sheet ---- */
+.rf-backdrop{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:70;opacity:0;pointer-events:none;transition:opacity .15s}
+.rf-backdrop.show{opacity:1;pointer-events:auto}
+.rf-sheet{position:fixed;left:50%;bottom:24px;z-index:71;transform:translate(-50%,24px);width:calc(100% - 32px);max-width:448px;background:#fff;border-radius:18px;padding:18px 18px 14px;opacity:0;pointer-events:none;transition:opacity .15s,transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
+.rf-sheet.show{opacity:1;transform:translate(-50%,0);pointer-events:auto}
+.rf-head{display:flex;align-items:center;gap:8px}
+.rf-head h3{margin:0;font-size:16px;font-weight:700;letter-spacing:-.01em;flex:1;min-width:0}
+.rf-head h3 small{display:block;font-size:11px;font-weight:500;color:var(--ink-soft);margin-top:2px}
+.rf-meta{display:flex;gap:8px;flex-wrap:wrap}
+.rf-chip{font-size:11px;font-weight:600;color:var(--ink-soft);border:1px solid var(--muted);border-radius:999px;padding:3px 9px}
+.rf-warn{background:#fff6f6;border:1px solid #f3c9c9;border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.45;color:#8a2b2b}
+.rf-items{border:1px solid var(--muted);border-radius:10px;overflow:hidden}
+.rf-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--muted);font-size:13px}
+.rf-item:last-child{border-bottom:0}
+.rf-item span{flex:1;min-width:0}
+.rf-item b{font-variant-numeric:tabular-nums;font-weight:600}
+.rf-total{display:flex;align-items:baseline;justify-content:space-between;font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--fg)}
+.rf-total b{font-variant-numeric:tabular-nums}
+.rf-actions{display:flex;gap:8px;margin-top:2px}
+.rf-actions button{flex:1;padding:13px;border-radius:12px;font-size:14px;font-weight:600;letter-spacing:-.005em;min-height:46px}
+.rf-actions .secondary{background:var(--surface);color:var(--fg);border:1px solid var(--border)}
+.rf-actions .danger{background:#c92a2a;color:#fff}
+.rf-actions .danger:disabled{opacity:.6}
+:root[data-theme="dark"] .sale-row .nm b em{color:#ff6680}
+:root[data-theme="dark"] .rf-sheet{background:#181818;box-shadow:0 20px 60px rgba(0,0,0,.6)}
+:root[data-theme="dark"] .rf-head h3 small{color:#888}
+:root[data-theme="dark"] .rf-chip{border-color:var(--muted);color:#888}
+:root[data-theme="dark"] .rf-warn{background:#2a1a1a;border-color:#5a2429;color:#ff9db0}
+:root[data-theme="dark"] .rf-item{border-bottom-color:var(--muted)}
+:root[data-theme="dark"] .rf-actions .secondary{background:#222;border-color:var(--muted)}
.method{padding:3px 8px;border-radius:999px;background:var(--surface);border:1px solid var(--border);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);flex:0 0 auto;white-space:nowrap}
.method.account{background:var(--accent-soft);border-color:var(--accent-2);color:var(--accent)}
@@ -158,6 +196,25 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shad
Loading…
+
+
+
+
+
Refund this sale?Sale — · —
+
+
+ Paid by —
+
+
+
+
Credit backR 0.00
+
+
+
+
+
+
diff --git a/public/pos/receipt.html b/public/pos/receipt.html
index 4242e90..b52dbf1 100644
--- a/public/pos/receipt.html
+++ b/public/pos/receipt.html
@@ -359,16 +359,51 @@ function paintNet(){
window.addEventListener('online', paintNet);
window.addEventListener('offline', paintNet);
-const sale = JSON.parse(sessionStorage.getItem('pos.lastSale')||'null');
+const isRefundView = /[?&]refund=1/.test(location.search);
+const refund = isRefundView
+ ? JSON.parse(sessionStorage.getItem('pos.lastRefund')||'null')
+ : null;
+
+// A credit slip reuses the whole sale renderer and the whole printer path —
+// the only difference is `refund: true`, which flips the wording in
+// renderReceipt and in epos-print.js. One printer path, one set of
+// settings, nothing new to keep working.
+const sale = refund
+ ? {
+ refund: true,
+ localId: (refund.saleNumber || '').slice(-8).toUpperCase(),
+ saleNumber: refund.saleNumber,
+ 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,
+ items: (refund.items||[]).map(it => ({
+ sku: it.sku, description: it.description,
+ qty: Number(it.qty)||0, selling_price: Number(it.selling_price)||0,
+ })),
+ customer: refund.customer || null,
+ letterhead: null,
+ synced: true,
+ }
+ : JSON.parse(sessionStorage.getItem('pos.lastSale')||'null');
if(!sale){
document.getElementById('receiptCard').innerHTML = '
No receipt
Start a new sale from the catalog.
';
document.getElementById('status').style.display='none';
document.getElementById('printBtn').style.display='none';
} else {
renderReceipt(sale);
- if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
+ if(sale.refund){
+ // Retitle the screen so nobody mistakes this for a fresh receipt, and
+ // say plainly that the money has already been handed back — the slip
+ // is a record, not an instruction to pay again.
+ document.getElementById('successTitle').textContent = 'Refunded';
+ document.getElementById('successSub').textContent =
+ `Credit slip for sale ${sale.saleNumber} — goods are back in stock.`;
+ document.getElementById('syncedFlag').style.display = 'none';
+ } else if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
- if(sale.payment==='account'){
+ if(sale.payment==='account' && !sale.refund){
document.getElementById('deliveryWrap').style.display='block';
// An account sale never gets a paper slip. The goods leave on the
// delivery note and that is the document the client signs, so
@@ -466,12 +501,15 @@ function renderReceipt(s){
${customerBlock}
${itemsHTML}
- ${grossBlock}
+ ${s.refund ? '' : grossBlock}
Subtotal${fmt(sub)}
VAT 15%${fmt(vat)}
-
Total${fmt(s.total)}
-
${esc(method)} Paid${paid}
- ${s.payment==='cash' ? `
Change${fmt(s.change||0)}
` : ''}
+
${s.refund ? 'CREDIT' : 'Total'}${fmt(s.total)}
+ ${s.refund
+ ? `
${esc(method)} Refunded${fmt(s.total)}
+
Goods returned to stock
`
+ : `
${esc(method)} Paid${paid}
+ ${s.payment==='cash' ? `
Change${fmt(s.change||0)}
` : ''}`}
${esc(lh.footer_text || 'Thank you for your purchase.')}
@@ -697,10 +735,14 @@ function saleEscPosRenderers(s){
}
out += `Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`;
out += `VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`;
- out += ESC+'!'+E(0x08) + `TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
+ out += ESC+'!'+E(0x08) + `${s.refund?'CREDIT':'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
return out;
},
payment: () => {
+ if(s.refund){
+ const how = s.payment==='cash' ? 'CASH' : s.payment==='account' ? 'ACCOUNT CREDIT' : s.payment.toUpperCase();
+ return 'Refunded by: ' + how + '\n' + 'Goods returned to stock.\n';
+ }
let out = 'Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n';
if(s.payment==='cash') out += `Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`;
return out;
diff --git a/public/pos/sw.js b/public/pos/sw.js
index 8905069..268c31f 100644
--- a/public/pos/sw.js
+++ b/public/pos/sw.js
@@ -7,6 +7,15 @@
// running the step-1 build of it indefinitely.
// Measured on the live catalog: "nuts m20" 0 → 12, "washers flat" 0 → 14,
// with nothing that used to match ceasing to match.
+// v35 — sale refunds. Hold a sale row in history.html to refund the whole
+// sale: the items go back into stock (stock_ledger reason "return", the
+// reason that already existed unused) and a credit slip prints reusing the
+// sale printer path with refund:true. Refunds are whole-sale only, so
+// pos_sales.refunded is a boolean and the server refuses a second one with
+// 409. cashup.html subtracts cash refunds from the expected drawer —
+// without that, every cash-up after a refund shows a false variance.
+// Bumped because history.html, cashup.html, receipt.html and epos-print.js
+// are all served cache-first out of the shell.
// v34 — escapePbValue now drops backslashes rather than escaping them:
// PB has no backslash escape in filters (1, 2 or 3 all throw), so a raw one
// 500s the request. v33 already removed the price field from the edit sheet.
@@ -42,7 +51,7 @@
// vertical height while staying visible.
// Bumped because pos-app.js and pos.html are both served cache-first out of
// the shell; without a new CACHE name the tills keep the old ones.
-const CACHE = "pos-v34";
+const CACHE = "pos-v35";
const APP_SHELL = [
'./',
'./index.html',
diff --git a/scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts b/scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts
new file mode 100644
index 0000000..4fbafb1
--- /dev/null
+++ b/scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts
@@ -0,0 +1,139 @@
+/* eslint-disable */
+/**
+ * Migration: add refund fields to `pos_sales`.
+ *
+ * ## Why
+ *
+ * A customer who brings goods back needs the sale reversed: the items go
+ * back into stock and the money comes back off the till. There was no way
+ * to mark a POS sale as refunded, so nothing stopped the same sale being
+ * refunded twice — which would credit the customer twice and put the
+ * stock back on the shelf twice.
+ *
+ * Refunds are WHOLE-SALE only by operator decision (2026-10-07): no
+ * partial refunds. That is what makes three plain columns enough — a sale
+ * is either refunded or it isn't, so a boolean is the complete answer and
+ * no `pos_refunds` collection is needed. If partial refunds are ever
+ * wanted this schema has to grow a per-line refund table; the boolean
+ * here becomes "fully refunded".
+ *
+ * The stock side needs no schema at all: `return` is already a member of
+ * `STOCK_REASON_VALUES`, it just has never been used. The refund writes
+ * `stock_ledger` rows with `reason: "return"`, positive `change_qty`,
+ * and `reference_type: "pos_sale_refund"` so the reversal is traceable
+ * back to the sale without overwriting the original
+ * `customer_fulfillment` rows. The ledger is the stock-side audit trail;
+ * these three columns are the money-side one.
+ *
+ * ## What it does
+ *
+ * Adds to `pos_sales`:
+ *
+ * - `refunded` (bool, default false) Has this sale been reversed?
+ * - `refunded_at` (date, optional) When.
+ * - `refund_amount` (number, optional) The rand value credited back.
+ * Stamped from the sale's
+ * post-discount `total`, which
+ * is what the customer actually
+ * paid.
+ *
+ * Idempotent — safe to re-run.
+ *
+ * Run with:
+ * npx tsx scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts
+ */
+import PocketBase from "pocketbase";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+function loadEnvFile(name: string) {
+ try {
+ const raw = readFileSync(resolve(process.cwd(), name), "utf8");
+ for (const line of raw.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+ const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
+ if (m && !process.env[m[1]]) {
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
+ }
+ }
+ } catch {
+ /* fine — rely on shell env */
+ }
+}
+loadEnvFile(".env");
+loadEnvFile(".env.local");
+
+const TAG = "[pos-sales-refund]";
+
+async function main() {
+ const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
+ const email = process.env.PB_ADMIN_EMAIL;
+ const password = process.env.PB_ADMIN_PASSWORD;
+ if (!url || !email || !password) {
+ throw new Error(
+ "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
+ );
+ }
+
+ const pb = new PocketBase(url);
+ await pb.collection("_superusers").authWithPassword(email, password);
+ console.log(`${TAG} Authenticated against ${url}`);
+
+ const posSales = await pb.collections.getOne("pos_sales");
+ const existing = new Set(
+ (posSales.fields as Array<{ name: string }>).map((f) => f.name),
+ );
+
+ // Key shapes copied from the live schema rather than invented — PB rejects
+ // a field object it doesn't recognise. `id` is generated by PB on write.
+ // `date` carries min/max "" and `number` min/max null, matching
+ // customers.dateadded and pos_sales.cash_given respectively.
+ const additions = [
+ {
+ name: "refunded",
+ type: "bool",
+ required: false,
+ presentable: false,
+ },
+ {
+ name: "refunded_at",
+ type: "date",
+ required: false,
+ presentable: false,
+ min: "",
+ max: "",
+ },
+ {
+ name: "refund_amount",
+ type: "number",
+ required: false,
+ presentable: false,
+ min: null,
+ max: null,
+ onlyInt: false,
+ },
+ ].filter((f) => !existing.has(f.name));
+
+ if (additions.length === 0) {
+ console.log(`${TAG} pos_sales refund fields already present — nothing to do.`);
+ return;
+ }
+
+ await pb.collections.update(posSales.id, {
+ fields: [...posSales.fields, ...additions],
+ });
+ console.log(`${TAG} Added to pos_sales: ${additions.map((f) => f.name).join(", ")}`);
+
+ const after = await pb.collections.getOne("pos_sales");
+ const nowHave = new Set((after.fields as Array<{ name: string }>).map((f) => f.name));
+ for (const f of additions) {
+ if (!nowHave.has(f.name)) throw new Error(`${TAG} field ${f.name} missing after write`);
+ }
+ console.log(`${TAG} Verified on the live schema.`);
+}
+
+main().catch((err) => {
+ console.error(`${TAG} FAILED:`, err);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/src/app/api/v1/pos/sales/[id]/refund/route.ts b/src/app/api/v1/pos/sales/[id]/refund/route.ts
new file mode 100644
index 0000000..85bfc55
--- /dev/null
+++ b/src/app/api/v1/pos/sales/[id]/refund/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from "next/server";
+import { assertPosAuth } from "@/lib/server/pos-auth";
+import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
+import { refundPosSale } from "@/lib/server/pos-service";
+
+/**
+ * POST /api/v1/pos/sales/[id]/refund
+ *
+ * Reverses a whole POS sale: every line goes back into stock (a
+ * `stock_ledger` row with `reason: "return"` and positive `change_qty`,
+ * tagged `pos_sale_refund`) and the money is credited back.
+ *
+ * Whole-sale only — there is no partial refund, which is what lets
+ * `pos_sales.refunded` be a boolean. The flag is stamped before any stock
+ * write, so a double tap or two tills racing the same sale credit the
+ * customer once, not twice; the loser gets 409 `already_refunded`.
+ *
+ * 409 is returned rather than a 200-with-`duplicate` shape, because the
+ * till needs to *stop* and tell the operator, not silently succeed at a
+ * second refund.
+ *
+ * Body (optional): { note? }
+ *
+ * The tender is deliberately NOT accepted from the client: a refund follows
+ * the ORIGINAL sale's payment method, read off the sale itself. Letting a
+ * caller assert "this cash sale was really an EFT" would let the drawer
+ * reconciliation be talked out of balance.
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ assertPosAuth(req);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unauthorized";
+ return NextResponse.json({ error: message }, { status: 401 });
+ }
+
+ const { id } = await params;
+
+ let note: string | undefined;
+ try {
+ const raw = await req.json();
+ if (raw && typeof raw === "object") {
+ const n = (raw as Record).note;
+ if (typeof n === "string" && n.trim()) note = n.trim().slice(0, 200);
+ }
+ } catch {
+ // An empty body is fine — the note is optional.
+ }
+
+ const pb = await getAdminPocketBase();
+ const result = await refundPosSale(pb as never, id, { note });
+
+ if (!result.ok) {
+ const status = result.reason === "already_refunded" ? 409 : 404;
+ return NextResponse.json(
+ { error: result.message, reason: result.reason },
+ { status },
+ );
+ }
+
+ return NextResponse.json({
+ ok: true,
+ sale: result.sale,
+ items: result.items,
+ refundAmount: result.refundAmount,
+ // Straight off the sale, never off the request.
+ tender: result.sale.payment_method,
+ stockErrors: result.stockErrors,
+ });
+}
diff --git a/src/lib/server/pos-refund.test.ts b/src/lib/server/pos-refund.test.ts
new file mode 100644
index 0000000..6e81f85
--- /dev/null
+++ b/src/lib/server/pos-refund.test.ts
@@ -0,0 +1,197 @@
+import { describe, it, expect } from "vitest";
+import { refundPosSale } from "./pos-service";
+import type { PosPb } from "./pos-service";
+
+/**
+ * Whole-sale refunds (`refundPosSale`).
+ *
+ * The failure that costs real money is a DOUBLE refund: the customer is
+ * credited twice and the same stock is put back on the shelf twice. It is
+ * guarded twice — a read of `pos_sales.refunded` up front, and the flag
+ * being stamped BEFORE any stock write so a concurrent second call bails.
+ * Both guards are asserted here.
+ */
+
+type Row = Record;
+
+function stubPb(opts: {
+ sale?: Row | null;
+ items?: Row[];
+ /** Make `getOne` on pos_sales throw, simulating a vanished row. */
+ saleMissing?: boolean;
+ /** Fail the ledger create, to exercise the stockErrors path. */
+ failLedgerFor?: string;
+}) {
+ const calls: { col: string; op: string; data?: Row }[] = [];
+ const stockQty = new Map([
+ ["si-1", 10],
+ ["si-2", 4],
+ ]);
+
+ const sale: Row = opts.sale === undefined ? defaultSale() : (opts.sale as Row);
+ let saleRow = sale;
+
+ const pb = {
+ collection: (name: string) => ({
+ getOne: async (id: string) => {
+ calls.push({ col: name, op: "getOne", data: { id } });
+ if (name === "pos_sales" && opts.saleMissing) throw new Error("404");
+ if (name === "stock_items") {
+ if (!stockQty.has(id)) throw new Error(`no such stock item ${id}`);
+ return { id, current_qty: stockQty.get(id) };
+ }
+ return saleRow;
+ },
+ update: async (id: string, data: Row) => {
+ calls.push({ col: name, op: "update", data: { ...data, id } });
+ if (name === "pos_sales") saleRow = { ...saleRow, ...data };
+ if (name === "stock_items" && typeof data.current_qty === "number") {
+ stockQty.set(id, data.current_qty);
+ }
+ return saleRow;
+ },
+ getFullList: async (o: Record = {}) => {
+ calls.push({ col: name, op: "getFullList" });
+ return name === "pos_sale_items" ? (opts.items ?? defaultItems()) : [];
+ },
+ create: async (data: Row) => {
+ calls.push({ col: name, op: "create", data });
+ if (name === "stock_ledger" && data.stock_item === opts.failLedgerFor) {
+ throw new Error("ledger write failed");
+ }
+ return data;
+ },
+ }),
+ } as unknown as PosPb;
+
+ return { pb, calls, stockQty, saleNow: () => saleRow };
+}
+
+function defaultSale(): Row {
+ return {
+ id: "sale1",
+ sale_number: "S-ABC123",
+ payment_method: "cash",
+ total: 100,
+ subtotal: 86.96,
+ vat: 13.04,
+ };
+}
+
+function defaultItems(): Row[] {
+ return [
+ { id: "i1", sale: "sale1", stock_item: "si-1", sku: "A", description: "Bolt", qty: 2, unit_price: 30, unit_cost: 10 },
+ { id: "i2", sale: "sale1", stock_item: "si-2", sku: "B", description: "Nut", qty: 1, unit_price: 40, unit_cost: 15 },
+ ];
+}
+
+describe("refundPosSale — happy path", () => {
+ it("stamps the sale and returns the credited amount", async () => {
+ const { pb, calls } = stubPb({});
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok).toBe(true);
+ if (!res.ok) return;
+ expect(res.refundAmount).toBe(100);
+ const stamp = calls.find((c) => c.col === "pos_sales" && c.op === "update");
+ expect(stamp?.data).toMatchObject({ refunded: true, refund_amount: 100 });
+ expect(String(stamp?.data?.refunded_at)).toMatch(/\d{4}-\d{2}-\d{2}/);
+ });
+
+ it("puts every line back into stock with a `return` ledger row", async () => {
+ const { pb, calls, stockQty } = stubPb({});
+ await refundPosSale(pb, "sale1");
+ const ledgers = calls.filter((c) => c.col === "stock_ledger" && c.op === "create");
+ expect(ledgers).toHaveLength(2);
+ for (const l of ledgers) {
+ expect(l.data).toMatchObject({ reason: "return", reference_type: "pos_sale_refund", reference_id: "sale1" });
+ // POSITIVE — stock comes back. The original rows stay untouched.
+ expect(Number(l.data?.change_qty)).toBeGreaterThan(0);
+ }
+ // 10 + 2 and 4 + 1
+ expect(stockQty.get("si-1")).toBe(12);
+ expect(stockQty.get("si-2")).toBe(5);
+ });
+
+ it("refunds the POST-discount total, which is what was paid", async () => {
+ const { pb } = stubPb({ sale: { ...defaultSale(), total: 87.5, discount_amount: 12.5 } });
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok && res.refundAmount).toBe(87.5);
+ });
+
+ it("reports the item lines for the credit slip", async () => {
+ const { pb } = stubPb({});
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok && res.items).toEqual([
+ { stock_item_id: "si-1", sku: "A", description: "Bolt", qty: 2, unit_price: 30, line_total: 60 },
+ { stock_item_id: "si-2", sku: "B", description: "Nut", qty: 1, unit_price: 40, line_total: 40 },
+ ]);
+ });
+});
+
+describe("refundPosSale — never refunds twice", () => {
+ it("refuses a sale already flagged refunded", async () => {
+ const { pb, calls } = stubPb({
+ sale: { ...defaultSale(), refunded: true, refund_amount: 100 },
+ });
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok).toBe(false);
+ if (res.ok) return;
+ expect(res.reason).toBe("already_refunded");
+ // Crucially: no stock writes at all on the second attempt.
+ expect(calls.filter((c) => c.col === "stock_ledger")).toHaveLength(0);
+ expect(calls.filter((c) => c.col === "stock_items" && c.op === "update")).toHaveLength(0);
+ });
+
+ it("stamps the flag BEFORE writing any stock row", async () => {
+ // If stock were written first and the flag last, two concurrent taps
+ // would both pass the guard and credit the customer twice.
+ const { pb, calls } = stubPb({});
+ await refundPosSale(pb, "sale1");
+ const flagAt = calls.findIndex(
+ (c) => c.col === "pos_sales" && c.op === "update" && c.data?.refunded === true,
+ );
+ const firstLedger = calls.findIndex((c) => c.col === "stock_ledger" && c.op === "create");
+ expect(flagAt).toBeGreaterThan(-1);
+ expect(firstLedger).toBeGreaterThan(-1);
+ expect(flagAt, "flag must be stamped before the first ledger write").toBeLessThan(firstLedger);
+ });
+
+ it("a second call on the SAME stub is refused after the first succeeded", async () => {
+ const { pb } = stubPb({});
+ const first = await refundPosSale(pb, "sale1");
+ expect(first.ok).toBe(true);
+ const second = await refundPosSale(pb, "sale1");
+ expect(second.ok).toBe(false);
+ if (!second.ok) expect(second.reason).toBe("already_refunded");
+ });
+
+ it("does NOT roll the flag back when a stock write fails", async () => {
+ // The customer has their money back either way. Un-stamping would let a
+ // retry credit them twice — strictly worse than a stock correction.
+ const { pb, saleNow } = stubPb({ failLedgerFor: "si-1" });
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok).toBe(true);
+ if (!res.ok) return;
+ expect(res.stockErrors).toHaveLength(1);
+ expect(res.stockErrors[0].stock_item_id).toBe("si-1");
+ expect(saleNow().refunded).toBe(true);
+ });
+});
+
+describe("refundPosSale — refusals", () => {
+ it("404s a sale that does not exist", async () => {
+ const { pb } = stubPb({ saleMissing: true });
+ const res = await refundPosSale(pb, "nope");
+ expect(res.ok).toBe(false);
+ if (!res.ok) expect(res.reason).toBe("not_found");
+ });
+
+ it("refuses a sale with no line items", async () => {
+ const { pb, calls } = stubPb({ items: [] });
+ const res = await refundPosSale(pb, "sale1");
+ expect(res.ok).toBe(false);
+ if (!res.ok) expect(res.reason).toBe("no_items");
+ // Nothing was written — a sale with no items is not a refund.
+ expect(calls.filter((c) => c.op === "update")).toHaveLength(0);
+ });
+});
\ No newline at end of file
diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts
index e98c706..5645983 100644
--- a/src/lib/server/pos-service.ts
+++ b/src/lib/server/pos-service.ts
@@ -643,6 +643,176 @@ export async function checkoutSale(
};
}
+// ─── refundPosSale ──────────────────────────────────────────────────
+
+export type PosRefundItem = {
+ stock_item_id: string;
+ sku: string;
+ description: string;
+ qty: number;
+ unit_price: number;
+ /** unit_price × qty */
+ line_total: number;
+};
+
+export type PosRefundResult =
+ | {
+ ok: true;
+ sale: PosSale;
+ items: PosRefundItem[];
+ /** Rand credited back — the sale's post-discount total. */
+ refundAmount: number;
+ /** True when this call found the sale already refunded and did nothing. */
+ duplicate: boolean;
+ /**
+ * Lines whose stock write failed AFTER the sale was stamped refunded.
+ * Reported rather than thrown: the customer has their money back, and
+ * silently retrying would be worse than a manual stock correction.
+ */
+ stockErrors: Array<{ stock_item_id: string; error: string }>;
+ }
+ | {
+ ok: false;
+ reason: "not_found" | "already_refunded" | "no_items";
+ message: string;
+ };
+
+/**
+ * Reverses a whole POS sale: puts every line back into stock and credits
+ * the money back. Whole-sale only by operator decision — there is no
+ * partial refund, which is why `pos_sales.refunded` is a boolean rather
+ * than a per-line refund table.
+ *
+ * The money comes back the way it went in, so a cash refund must also be
+ * subtracted from the expected drawer in `cashup.html`; a card/EFT refund
+ * settles with the acquirer, and an `account` sale becomes a reduction of
+ * what the customer owes rather than a cash movement. `refundTender` says
+ * which, so the slip can print the right wording.
+ *
+ * DOUBLE-REFUND PROTECTION. This is the one failure that costs real money,
+ * so it is guarded twice rather than once:
+ *
+ * 1. `pos_sales.refunded` is read immediately before any write, and a
+ * sale that is already flagged returns `already_refunded`.
+ * 2. The flag is stamped FIRST, before the stock rows are written. If two
+ * taps land concurrently, the second sees the flag and does nothing.
+ * Writing stock first and flagging last would let both taps through.
+ *
+ * A stock write that fails part-way does NOT roll the flag back — the
+ * customer is refunded either way, so re-running would double-credit them.
+ * The failed lines come back in `stockErrors` for a manual correction, which
+ * is strictly better than a second refund.
+ *
+ * The stock side needs no schema: `return` is already in
+ * `STOCK_REASON_VALUES`. Rows are written with POSITIVE `change_qty` and
+ * `reference_type: "pos_sale_refund"`, leaving the original
+ * `customer_fulfillment` rows untouched so the sale's own audit trail still
+ * shows what was originally rung up.
+ */
+export async function refundPosSale(
+ pb: PosPb,
+ saleId: string,
+ opts: { note?: string } = {},
+): Promise {
+ let sale: PosSale;
+ try {
+ sale = await pb.collection("pos_sales").getOne(saleId);
+ } catch {
+ return { ok: false, reason: "not_found", message: `Sale ${saleId} could not be found.` };
+ }
+
+ // Guard 1 — cheap check before we read the items, so a double-tap
+ // doesn't even pay for the item query.
+ if (sale.refunded) {
+ return {
+ ok: false,
+ reason: "already_refunded",
+ message:
+ sale.refund_amount != null
+ ? `This sale was already refunded (${(Number(sale.refund_amount) / 100).toFixed(2)}).`
+ : "This sale has already been refunded.",
+ };
+ }
+
+ const items = await pb.collection("pos_sale_items").getFullList({
+ filter: `sale = '${escapePbValue(saleId)}'`,
+ });
+
+ if (!items.length) {
+ return { ok: false, reason: "no_items", message: "This sale has no line items to return." };
+ }
+
+ const refundAmount = round2(Number(sale.total ?? 0));
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 19) + ".000Z";
+
+ // Guard 2 — stamp the flag BEFORE any stock write, so a concurrent second
+ // call sees it and bails.
+ try {
+ sale = await pb.collection("pos_sales").update(saleId, {
+ refunded: true,
+ refunded_at: stamp,
+ refund_amount: refundAmount,
+ });
+ } catch (err) {
+ // Nothing has been written yet, so failing here is safe and must NOT
+ // be treated as "already refunded" — the sale is still live.
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ ok: false,
+ reason: "not_found",
+ message: `Could not mark the sale as refunded, so nothing was returned: ${message}`,
+ };
+ }
+
+ const stockErrors: Array<{ stock_item_id: string; error: string }> = [];
+ const refunded: PosRefundItem[] = [];
+
+ for (const it of items) {
+ const qty = Math.trunc(Number(it.qty ?? 0));
+ const unitPrice = Number(it.unit_price ?? 0) || 0;
+ const stockItemId = String(it.stock_item || "");
+ try {
+ if (stockItemId && qty > 0) {
+ await pb.collection("stock_ledger").create({
+ stock_item: stockItemId,
+ // Positive: stock comes BACK. The original rows stay as they are.
+ change_qty: qty,
+ reason: "return",
+ reference_type: "pos_sale_refund",
+ reference_id: saleId,
+ notes: `Refund of ${sale.sale_number || saleId}${opts.note ? " — " + opts.note : ""}`,
+ created_by: "",
+ });
+
+ const current = await pb.collection("stock_items").getOne<{
+ current_qty?: number;
+ }>(stockItemId);
+ const nextQty = Number(current.current_qty ?? 0) + qty;
+ await pb.collection("stock_items").update(stockItemId, { current_qty: nextQty });
+ }
+ refunded.push({
+ stock_item_id: stockItemId,
+ sku: String(it.sku || ""),
+ description: String(it.description || ""),
+ qty,
+ unit_price: unitPrice,
+ line_total: round2(unitPrice * qty),
+ });
+ } catch (err) {
+ stockErrors.push({
+ stock_item_id: stockItemId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ return { ok: true, sale, items: refunded, refundAmount, duplicate: false, stockErrors };
+}
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
// ─── listSalesInRange ───────────────────────────────────────────────
export type PosSalesRangeResult = {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 62f4185..410e541 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1709,6 +1709,23 @@ export interface PosSale extends BaseRecord {
total: number;
/** customers relation id, or "" for a walk-in with no customer picked. */
customer: string;
+ /**
+ * Has this sale been reversed? Refunds are WHOLE-SALE only, so a boolean
+ * is the complete answer — there is no per-line refund table. Unset on
+ * every sale predating the feature, which is falsy, i.e. "not refunded".
+ *
+ * Stamped BEFORE the refund's stock rows are written, so a second
+ * concurrent call sees it and bails rather than crediting twice.
+ */
+ refunded?: boolean;
+ /** When the refund happened, PB datetime format. */
+ refunded_at?: string;
+ /**
+ * Rand credited back. Stamped from the sale's post-discount `total`,
+ * which is what the customer actually paid — a discounted sale refunds
+ * the discounted amount, not the shelf price.
+ */
+ refund_amount?: number;
/**
* Client-generated idempotency key (the POS device's local sale id).
* A sale that was committed server-side but whose response never