WFDASH-WEBAPP/public/pos/sw.js
Dave 7256de8859 feat(pos): refund a whole sale, put the goods back, print a credit slip
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).
2026-10-07 23:41:09 +02:00

138 lines
6.4 KiB
JavaScript

// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
// v30 — step 2 of the catalog search work: pos-search.js gains plural
// handling ("nuts" also finds "nut", so "bolts" goes from 0 hits to the 884
// real bolt rows) and glued-token splitting ("M20NUT" finds "M20 Hex Nut").
// Bumped again even though the shell already contained pos-search.js: that
// file is served cache-first, so without a new CACHE name tills would keep
// 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.
// v34 — the account-sale customer picker now matches the COMPANY name as
// well as the person's name/number/email, so typing "Sibanye" returns that
// mine's staff instead of nothing. /api/v1/pos/customers also returns a
// real `total` now: the old `slice(0, 8)` silently showed 8 of 52 people
// at the largest company and read as the whole team, so the till renders
// "showing N of M" and says so when the list is cut. Name matches rank
// above company-only matches so an OR'd filter can't bury the person the
// cashier typed. escapePbValue also escapes backslashes now — a single
// "\" in the search box used to throw and 500 the request.
// v33 — the Edit line sheet is quantity-only. The Unit price field is gone
// on request: the till sells at the catalogue shelf price, and a mis-keyed
// price is expensive and easy to miss. The per-line price override it fed is
// removed with it — the server only ever read `unit_price`, and the
// "price edited" label plus the sale record's `edited` flag are now
// unreachable. Nothing else could set an override, including a cart reload.
// v32 — fix the Edit line sheet, which never actually displayed. #editSheet
// is a sibling of #editBackdrop, but the CSS revealed it with
// `.edit-backdrop.show .edit-sheet`, a descendant selector that can never
// match a sibling — so the sheet sat at opacity:0 while the backdrop went
// opaque. Long-pressing a cart row produced a grey screen with no dialog.
// The sheet now carries its own z-index above the backdrop and its own
// .show rule. This is why manual quantity entry looked missing: the control
// existed and worked, it was just rendering underneath its own backdrop.
// Bumped because pos.html is served cache-first out of the shell.
// v31 — cart quantity is now directly editable: the number in each cart line
// is a real button that opens the existing Edit line sheet (previously only
// reachable by an undiscoverable 600ms long-press), and holding +/- no
// longer pops that sheet open mid-tap. The discount trigger moved out of its
// own 54px footer row to ride inside the Subtotal row, so it costs no
// 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-v35";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./settings.html',
'./slip-layout.html',
'./pos-config.js',
'./pos-search.js',
'./pos-app.js',
'./letterhead-config.js',
'./slip-layout.js',
'./epos-print.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
// Files that must always be fresh (network-first, fallback to cache)
const NETWORK_FIRST = new Set([
'theme.js'
]);
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(APP_SHELL)).then(()=>self.skipWaiting()));
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(()=>self.clients.claim()));
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Never cache API responses — only the static app shell. Serving a
// stale /catalog or, worse, a cached /checkout would be actively wrong.
if(url.pathname.startsWith('/api/')) return;
// HTML navigations: always network-first so the user gets the latest page
if(e.request.mode === 'navigate'){
e.respondWith(
fetch(e.request).then(res => {
if(res && res.ok) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => caches.match(e.request).then(r => r || new Response('<h1>Offline</h1>', {status:503, headers:{'Content-Type':'text/html'}})))
);
return;
}
if(e.request.method !== 'GET') return;
// theme.js (and other network-first assets) always go to network
const fname = url.pathname.split('/').pop();
if(NETWORK_FIRST.has(fname)){
e.respondWith(
fetch(e.request).then(res => {
if(res && res.ok){
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => caches.match(e.request).then(r => r || new Response('', {status:504})))
);
return;
}
// Default: cache-first for static assets
e.respondWith(
caches.match(e.request).then(cached => {
const fetchP = fetch(e.request).then(res => {
if(res && res.ok && (e.request.url.startsWith('http'))) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => cached);
return cached || fetchP;
})
);
});