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