From 7256de88594329199a3e7c8ea354adc2e1be133b Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 7 Oct 2026 23:41:09 +0200 Subject: [PATCH] feat(pos): refund a whole sale, put the goods back, print a credit slip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 credits the customer twice and puts the same stock back on the shelf twice. Refunds are WHOLE-SALE only by operator decision; no partial refunds. That is what lets `pos_sales.refunded` be a boolean instead of a per-line refund table. Three columns: `refunded`, `refunded_at`, `refund_amount`, stamped from the sale's post-discount total, which is what the customer actually paid. DOUBLE-REFUND GUARD, twice over, because this is the one failure that costs real money: - `refunded` is read before anything else, and a flagged sale returns 409 with no stock writes at all - the flag is stamped BEFORE any stock write, so a second concurrent tap sees it and bails. 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 has their money either way, so re-running would double-credit them; the failed lines come back in `stockErrors` for a manual count correction, which is strictly better than a second refund. Stock needs no schema: `return` was already in STOCK_REASON_VALUES, just never used. 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. cashup.html had expected drawer = sum of cash sales, so a cash refund would have shown as a false variance on every cash-up after it. It now subtracts cash refunds, and refunded sales contribute nothing to takings for any tender. The tender is read off the sale, never off the request — an earlier version accepted it in the body, which would have let a caller assert that a cash sale was really an EFT and talk the drawer out of balance. At the till: tap still reprints, hold a sale row for 600ms to refund — moving money sits behind a gesture you cannot do by accident. Refunded sales stay in the list, dimmed and badged, because deleting them would destroy the audit trail. The sheet names the items and says plainly what happens next ("Hand back R10.57 in cash. The drawer count for this day will drop by that amount."). The credit slip reuses the sale renderer and the whole printer path with `refund: true`, so there is no second printer to configure — but note there were THREE render paths to fix (on-screen, Bluetooth text, ePOS XML), and missing the third would have printed "TOTAL" on a credit note. Migration applied to live PB and verified idempotent; field shapes round-tripped on a real sale and restored. 10 new tests, 148 POS tests pass, tsc clean, build passes. sw.js v34 -> v35 (history, cashup, receipt and epos-print are all cache-first in the shell). --- public/pos/cashup.html | 23 +- public/pos/epos-print.js | 29 ++- public/pos/history.html | 183 +++++++++++++++- public/pos/receipt.html | 58 +++++- public/pos/sw.js | 11 +- .../2026-10-07-add-pos-sales-refund-fields.ts | 139 ++++++++++++ src/app/api/v1/pos/sales/[id]/refund/route.ts | 73 +++++++ src/lib/server/pos-refund.test.ts | 197 ++++++++++++++++++ src/lib/server/pos-service.ts | 170 +++++++++++++++ src/lib/types.ts | 17 ++ 10 files changed, 879 insertions(+), 21 deletions(-) create mode 100644 scripts/migrations/2026-10-07-add-pos-sales-refund-fields.ts create mode 100644 src/app/api/v1/pos/sales/[id]/refund/route.ts create mode 100644 src/lib/server/pos-refund.test.ts 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…

+ +
+ + 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