WFDASH-WEBAPP/public/pos/cashup.html
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

510 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#ff385c">
<title>Cash-up</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
<style>
:root{
--bg:#ffffff;--surface:#fff7f8;--surface-2:#f7f7f7;--surface-card:#ffffff;
--fg:#222222;--ink-soft:#717171;--ink-faint:#999999;
--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;
--ok:#0f8a3f;--ok-soft:#e8f5ec;--warn:#b14a00;--warn-soft:#fdf2e6;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--fg);font:15px/1.45 var(--font);-webkit-font-smoothing:antialiased;overflow-x:hidden}
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input{font:inherit;color:inherit}
button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:10px}
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible,.topbar .netpill:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
/* ---- Topbar ---- */
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 10px;background:var(--bg);position:sticky;top:0;z-index:10}
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);flex:0 0 auto}
.topbar .back:active{background:var(--surface)}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card);flex:0 0 auto;white-space:nowrap}
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
.theme-btn:active{background:var(--surface)}
.theme-btn svg{width:18px;height:18px}
.theme-btn .i-moon{display:none}
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
/* ---- Day tabs ---- */
.day-tabs{display:flex;gap:6px;padding:10px 16px 12px;background:var(--bg);position:sticky;top:60px;z-index:9;border-bottom:1px solid var(--muted)}
.day-tab{flex:1;padding:10px;border:1px solid var(--muted);border-radius:10px;background:var(--surface-card);font-size:13px;font-weight:500;text-align:center;color:var(--fg);transition:background .12s ease,color .12s ease,border-color .12s ease}
.day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
/* ---- KPIs ---- */
.kpis{padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.kpi{padding:16px;border:1px solid var(--muted);border-radius:14px;background:var(--surface-card);box-shadow:var(--shadow)}
.kpi .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:6px}
.kpi .val{font-size:22px;font-weight:700;letter-spacing:-.02em;color:var(--fg);font-variant-numeric:tabular-nums;line-height:1.1}
.kpi .sub{font-size:11px;color:var(--ink-soft);margin-top:2px;letter-spacing:.01em}
.kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
.kpi.accent .val{color:var(--accent)}
/* ---- Methods ---- */
.methods{padding:0 16px 16px;display:flex;flex-direction:column;gap:8px}
.method-row{display:flex;align-items:center;gap:12px;padding:14px 16px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);box-shadow:var(--shadow)}
.method-row .lbl{font-size:13px;font-weight:600;letter-spacing:-.01em;flex:1;color:var(--fg)}
.method-row .ic{width:32px;height:32px;border-radius:8px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
.method-row .ic svg{width:18px;height:18px;stroke-width:1.7}
.method-row .count{font-size:11px;color:var(--ink-soft);margin-right:8px;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.method-row .pr{font-size:16px;font-weight:700;letter-spacing:-.01em;font-variant-numeric:tabular-nums;color:var(--fg);min-width:90px;text-align:right}
/* ---- Denominations ---- */
.denoms{padding:0 16px 16px}
.denoms h3{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin:8px 0 8px}
.denom{display:grid;grid-template-columns:60px 1fr 100px;gap:10px;align-items:center;padding:10px 0;border-bottom:1px solid var(--muted)}
.denom:last-child{border-bottom:0}
.denom .lbl{font-size:14px;font-weight:600;letter-spacing:-.01em;color:var(--fg)}
.denom .lbl small{font-size:11px;color:var(--ink-soft);font-weight:500;margin-left:4px;letter-spacing:.04em}
.denom input{width:100%;padding:11px 12px;border:1px solid var(--muted);border-radius:10px;font-size:16px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;outline:0;background:var(--surface-card);min-width:0;color:var(--fg)}
.denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
.denom .sub{font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg);text-align:right;letter-spacing:-.005em}
/* ---- Summary ---- */
.summary{padding:0 16px 16px;border-top:1px solid var(--muted);margin-top:6px;padding-top:14px}
.summary .row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;font-size:14px;color:var(--ink-soft);letter-spacing:.01em;gap:8px}
.summary .row b{color:var(--fg);font-variant-numeric:tabular-nums;font-weight:600}
.summary .row.diff b{color:var(--warn)}
.summary .row.diff.ok b{color:var(--ok)}
.summary .row.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:16px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
/* ---- Actions ---- */
.actions{padding:0 16px calc(20px + env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:10px;margin-top:auto}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:12px;font-size:15px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:var(--surface-card);color:var(--fg);transition:transform .12s ease,background .12s ease}
.btn:active{transform:translateY(1px);background:var(--surface)}
.btn svg{width:20px;height:20px;stroke-width:1.7}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 4px 14px rgba(255,56,92,.25)}
.btn.primary:active{background:#e5324f;box-shadow:none}
.skeleton{padding:24px 16px;text-align:center;color:var(--ink-faint);font-size:13px}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
--muted:#2a2a2a;--border:#3a2429;
--accent-soft:#2a1a1a;--accent-2:#3a2429;
--ok-soft:#1a2e22;--warn-soft:#2a1a1a;--danger-soft:#2a1a1a;--danger-2:#3a2429;
}
:root:not([data-theme="light"]) body{background:var(--bg);color:var(--fg)}
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
:root:not([data-theme="light"]) .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .day-tabs{background:var(--bg);border-bottom-color:var(--muted)}
:root:not([data-theme="light"]) .day-tab{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
:root:not([data-theme="light"]) .kpi{background:var(--surface-card);border-color:var(--muted)}
:root:not([data-theme="light"]) .kpi .lbl{color:var(--ink-soft)}
:root:not([data-theme="light"]) .kpi .val{color:var(--fg)}
:root:not([data-theme="light"]) .kpi .sub{color:var(--ink-soft)}
:root:not([data-theme="light"]) .kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
:root:not([data-theme="light"]) .kpi.accent .val{color:#ff8a9e}
:root:not([data-theme="light"]) .method-row{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .method-row .lbl{color:var(--fg)}
:root:not([data-theme="light"]) .method-row .ic{background:var(--accent-soft);color:#ff8a9e}
:root:not([data-theme="light"]) .method-row .count{color:var(--ink-soft)}
:root:not([data-theme="light"]) .method-row .pr{color:var(--fg)}
:root:not([data-theme="light"]) .denoms h3{color:var(--ink-soft)}
:root:not([data-theme="light"]) .denom{border-bottom-color:var(--muted)}
:root:not([data-theme="light"]) .denom .lbl{color:var(--fg)}
:root:not([data-theme="light"]) .denom .lbl small{color:var(--ink-soft)}
:root:not([data-theme="light"]) .denom input{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root:not([data-theme="light"]) .denom .sub{color:var(--fg)}
:root:not([data-theme="light"]) .summary{border-top-color:var(--muted)}
:root:not([data-theme="light"]) .summary .row{color:var(--ink-soft)}
:root:not([data-theme="light"]) .summary .row b{color:var(--fg)}
:root:not([data-theme="light"]) .summary .row.diff b{color:#ffa552}
:root:not([data-theme="light"]) .summary .row.diff.ok b{color:#5fc77c}
:root:not([data-theme="light"]) .summary .row.grand{border-top-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
:root:not([data-theme="light"]) .skeleton{color:var(--ink-faint)}
}
:root[data-theme="dark"]{
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
--muted:#2a2a2a;--border:#3a2429;
--accent-soft:#2a1a1a;--accent-2:#3a2429;
--ok-soft:#1a2e22;--warn-soft:#2a1a1a;--danger-soft:#2a1a1a;--danger-2:#3a2429;
background:var(--bg);color:var(--fg);
}
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
:root[data-theme="dark"] .topbar{background:var(--bg)}
:root[data-theme="dark"] .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .day-tabs{background:var(--bg);border-bottom-color:var(--muted)}
:root[data-theme="dark"] .day-tab{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
:root[data-theme="dark"] .kpi{background:var(--surface-card);border-color:var(--muted)}
:root[data-theme="dark"] .kpi .lbl{color:var(--ink-soft)}
:root[data-theme="dark"] .kpi .val{color:var(--fg)}
:root[data-theme="dark"] .kpi .sub{color:var(--ink-soft)}
:root[data-theme="dark"] .kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
:root[data-theme="dark"] .kpi.accent .val{color:#ff8a9e}
:root[data-theme="dark"] .method-row{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .method-row .lbl{color:var(--fg)}
:root[data-theme="dark"] .method-row .ic{background:var(--accent-soft);color:#ff8a9e}
:root[data-theme="dark"] .method-row .count{color:var(--ink-soft)}
:root[data-theme="dark"] .method-row .pr{color:var(--fg)}
:root[data-theme="dark"] .denoms h3{color:var(--ink-soft)}
:root[data-theme="dark"] .denom{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .denom .lbl{color:var(--fg)}
:root[data-theme="dark"] .denom .lbl small{color:var(--ink-soft)}
:root[data-theme="dark"] .denom input{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
:root[data-theme="dark"] .denom .sub{color:var(--fg)}
:root[data-theme="dark"] .summary{border-top-color:var(--muted)}
:root[data-theme="dark"] .summary .row{color:var(--ink-soft)}
:root[data-theme="dark"] .summary .row b{color:var(--fg)}
:root[data-theme="dark"] .summary .row.diff b{color:#ffa552}
:root[data-theme="dark"] .summary .row.diff.ok b{color:#5fc77c}
:root[data-theme="dark"] .summary .row.grand{border-top-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .btn:active{background:var(--surface)}
:root[data-theme="dark"] .skeleton{color:var(--ink-faint)}
@media (prefers-reduced-motion: reduce){
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
}
@media print{
.topbar,.theme-btn,.actions,.netpill{display:none !important}
body{background:#fff}
.kpi,.method-row,.btn{box-shadow:none;background:#fff}
}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='index.html'" aria-label="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h1>Cash-up</h1>
<span class="netpill" id="netpill" data-od-id="netpill">…</span>
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</header>
<div class="day-tabs" id="days">
<button class="day-tab active" data-shift="today">Today</button>
<button class="day-tab" data-shift="yesterday">Yesterday</button>
</div>
<div class="kpis" id="kpis">
<div class="kpi" data-od-id="kpi-sales"><div class="lbl">Sales</div><div class="val" id="kCount">—</div><div class="sub" id="kRange">—</div></div>
<div class="kpi accent" data-od-id="kpi-takings"><div class="lbl">Takings</div><div class="val" id="kTotal">R 0</div><div class="sub">All methods</div></div>
</div>
<div class="methods" id="methods"></div>
<div class="denoms">
<h3>Cash counted in drawer</h3>
<div id="denoms"></div>
</div>
<div class="summary">
<div class="row"><span>Cash sales</span><b id="sCashSales">R 0.00</b></div>
<div class="row"><span>Cash counted</span><b id="sCashCounted">R 0.00</b></div>
<div class="row diff" id="diffRow"><span>Variance</span><b id="sDiff">R 0.00</b></div>
<div class="row grand"><span>Takings</span><b id="sGrand">R 0.00</b></div>
</div>
<div class="actions">
<button class="btn primary" data-od-id="print-cashup" onclick="printCashup()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
Print cash-up
</button>
<button class="btn" data-od-id="home-btn" onclick="location.href='index.html'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 11l9-7 9 7v9h-6v-6h-6v6H3z"/></svg>
Home
</button>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.js"></script>
<script>
(function(){
'use strict';
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
// Network status indicator
function paintNet(){
const p = document.getElementById('netpill');
if(!p) return;
p.textContent = navigator.onLine ? 'Online' : 'Offline';
p.classList.toggle('off', !navigator.onLine);
}
window.addEventListener('online', paintNet);
window.addEventListener('offline', paintNet);
paintNet();
const DENOMS = [
{lbl:'R200', sub:'note', v:200}, {lbl:'R100', sub:'note', v:100}, {lbl:'R50', sub:'note', v:50}, {lbl:'R20', sub:'note', v:20},
{lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1},
{lbl:'50c', sub:'coin', v:0.5}, {lbl:'20c', sub:'coin', v:0.2}, {lbl:'10c', sub:'coin', v:0.1}
];
let shift = 'today';
let cashSales = 0, cashCounted = 0, totals = {cash:0, card:0, eft:0, account:0, count:0}, grand = 0;
document.querySelectorAll('.day-tab').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('.day-tab').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
shift = b.dataset.shift;
load();
}));
function paintDenoms(){
document.getElementById('denoms').innerHTML = DENOMS.map(d=>`
<div class="denom" data-v="${d.v}">
<div class="lbl">${d.lbl}<small>${d.sub}</small></div>
<input type="number" min="0" step="1" placeholder="0" inputmode="numeric" oninput="window.__cu.recalc()">
<div class="sub">R 0.00</div>
</div>
`).join('');
}
function getDenomCounts(){
const counts = {};
document.querySelectorAll('.denom').forEach(row=>{
const v = Number(row.dataset.v);
const inp = row.querySelector('input');
const n = Math.max(0, Math.floor(Number(inp.value)||0));
counts[v] = n;
row.querySelector('.sub').textContent = fmt(n * v);
});
return counts;
}
function recalc(){
const c = getDenomCounts();
// Round to whole cents: 10c/20c aren't exact in binary floating point,
// so a drawer of 3x10c would otherwise total 0.30000000000000004 and
// print a variance that looks like a rounding bug on the slip.
cashCounted = Math.round(Object.entries(c).reduce((s,[v,n])=>s + v*n, 0) * 100) / 100;
document.getElementById('sCashCounted').textContent = fmt(cashCounted);
const diff = cashCounted - cashSales;
const drow = document.getElementById('diffRow');
drow.classList.toggle('ok', Math.abs(diff)<0.5);
document.getElementById('sDiff').textContent = fmt(diff);
document.getElementById('sGrand').textContent = fmt(grand);
}
window.__cu = { recalc };
function paintMethods(){
const m = document.getElementById('methods');
m.innerHTML = `
<div class="method-row" data-od-id="method-cash">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/></svg></span>
<div class="lbl">Cash</div>
<div class="count" id="mCashN">0 sales</div>
<div class="pr">${fmt(totals.cash)}</div>
</div>
<div class="method-row" data-od-id="method-card">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg></span>
<div class="lbl">Card</div>
<div class="count" id="mCardN">0 sales</div>
<div class="pr">${fmt(totals.card)}</div>
</div>
<div class="method-row" data-od-id="method-eft">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg></span>
<div class="lbl">EFT</div>
<div class="count" id="mEftN">0 sales</div>
<div class="pr">${fmt(totals.eft)}</div>
</div>
<div class="method-row" data-od-id="method-account">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></span>
<div class="lbl">Account</div>
<div class="count" id="mAccN">0 sales</div>
<div class="pr">${fmt(totals.account)}</div>
</div>
`;
}
async function load(){
const d = new Date();
if(shift==='yesterday') d.setDate(d.getDate()-1);
const start = d.toISOString().slice(0,10) + ' 00:00:00';
const end = d.toISOString().slice(0,10) + ' 23:59:59';
document.getElementById('kRange').textContent = d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
document.getElementById('kCount').textContent = '—';
document.getElementById('kTotal').textContent = 'R 0';
try{
const r = await window.posFetch(`/sales?from=${encodeURIComponent(start)}&to=${encodeURIComponent(end)}`);
if(!r.ok) throw new Error('sales returned ' + r.status);
const j = await r.json();
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, 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();
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';
recalc();
}catch(e){
document.getElementById('kCount').textContent = '—';
document.getElementById('kTotal').textContent = '—';
}
}
paintDenoms();
load();
// ---------------- Print cash-up slip ----------------
// Sends the cashup report to the configured printer using the
// ePOS-Print transport (Network or Bluetooth — whatever the till
// is set to in Printer settings). Replaces the old `window.print()`
// which went to the OS print dialog instead of the thermal printer.
async function printCashup(){
const btn = document.querySelector('[data-od-id="print-cashup"]');
// Keep the markup, not just the text — the button holds a printer <svg>,
// and setting textContent to restore it would drop the icon for good.
const oldLabel = btn ? btn.innerHTML : '';
if(btn){ btn.disabled = true; btn.textContent = 'Printing…'; }
try{
// Letterhead must be loaded — same as the sale slip.
const letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
alert('No letterhead selected. Open Settings to pick one.');
return;
}
// Read the operator-typed denomination counts from the DOM.
// (They live in the <input> children of .denom — the rest of
// the page never needs them as JS state, just for display.)
const denoms = Array.from(document.querySelectorAll('.denom')).map(row => ({
label: row.querySelector('.lbl').firstChild ? row.querySelector('.lbl').childNodes[0].textContent.trim() : '',
value: Number(row.dataset.v) || 0,
count: Math.max(0, Math.floor(Number(row.querySelector('input').value) || 0)),
}));
const dateLabel = document.getElementById('kRange').textContent || '';
const counts = {
cash: (totals && totals.count) ? Math.round(totals.cash > 0 ? cashSales / Math.max(1, totals.cash) * totals.count : 0) : 0,
card: 0,
eft: 0,
account: 0,
};
// The page already tallies totals but not counts per method; the
// simplest fix is to read them from the "X sales" labels we
// already paint. Fallback to 0 if the labels aren't ready.
const numFromLabel = id => {
const el = document.getElementById(id);
if(!el) return 0;
const m = (el.textContent || '').match(/(\d+)/);
return m ? Number(m[1]) : 0;
};
counts.cash = numFromLabel('mCashN');
counts.card = numFromLabel('mCardN');
counts.eft = numFromLabel('mEftN');
counts.account = numFromLabel('mAccN');
const report = {
shiftLabel: shift === 'yesterday' ? 'Yesterday' : 'Today',
dateLabel,
counts,
totals: {
cash: cashSales,
card: totals.card,
eft: totals.eft,
account: totals.account,
},
grand,
cashCounted,
variance: cashCounted - cashSales,
denoms,
};
const settings = window.EposPrint.getSettings();
const cashupBlocks = window.PosSlipLayout.getBlocksForPrint(letterhead.id, 'cashup');
const cashupLogo = await window.EposPrint.letterheadLogoRaster(letterhead, 384);
const xml = window.EposPrint.buildEposCashupXml(letterhead, report, cashupBlocks, cashupLogo);
if(settings.type === 'network'){
if(!settings.ip){
// The printer form lives on the slip screen, not the Settings
// page — tapping the "Printer: …" row there expands it. There is
// only one saved setting (pos.printerSettings), shared with the
// sale slip, so point the operator at the one place it exists.
alert('No printer IP set. Print a sale slip, tap the "Printer: …" row on that screen, and save the IP there — the cash-up slip uses the same printer.');
return;
}
await window.EposPrint.printViaNetwork(settings.ip, xml);
} else {
// Bluetooth. The ESC/POS builders live in receipt.html, so there's
// no cash-up renderer for this transport yet — only the sale slip
// speaks Bluetooth. Say so plainly rather than quietly opening the
// OS print dialog: getSettings() DEFAULTS to bluetooth, so a till
// with cleared settings would otherwise land here and look like the
// button does nothing at all. Same stance as EposPrint.printTest().
throw new Error(
'The cash-up slip prints over the network printer only. ' +
'Print a sale slip, tap the "Printer: …" row on that screen, ' +
'and switch it to Network with the printer IP.'
);
}
}catch(e){
alert('Cash-up print failed: ' + (e.message || e));
}finally{
if(btn){ btn.disabled = false; btn.innerHTML = oldLabel; }
}
}
// This whole script is IIFE-wrapped, so `printCashup` is NOT a global —
// and the button wires it up with an inline onclick="printCashup()",
// which is evaluated in global scope. Without this line the click throws
// ReferenceError into the console and the button silently does nothing.
// Same fix, same reason, as receipt.html's print buttons.
window.printCashup = printCashup;
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>