fix(pos): make cart quantity editable, and fix the sheet that hid it
Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.
- the quantity is now a real button that opens the Edit line sheet,
with an aria-label and a 44px touch target (it was 30px, too narrow
to even show "800")
- holding +/- no longer pops the sheet open mid-tap: the pointerdown
handler bails for .qty/.rm before starting the timer
- the sheet is quantity-only. The Unit price field is gone on request;
a mis-keyed price is expensive and easy to miss. The per-line
override it fed is removed with it — pos-service.ts only ever read
unit_price, the "price edited" label and the sale record's `edited`
flag were unreachable, and nothing else could set an override,
including a cart reload (persistCart saves only {id, qty, p})
The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.
Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #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. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.
Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.
sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POS cart quantity entry (`public/pos/pos-app.js` + `public/pos/pos.html`).
|
|
|
|
|
*
|
|
|
|
|
* Ringing up 800 of something means typing 800, and the only route to the
|
|
|
|
|
* quantity field used to be a 600ms long-press on the row. The number
|
|
|
|
|
* itself was a plain `<div class="v">` — exactly the thing a cashier reaches
|
|
|
|
|
* for — so it rendered inert. The sheet it opens is unchanged and still
|
|
|
|
|
* reachable both ways; these guards exist because the *discoverability* is
|
|
|
|
|
* what regressed, and that is invisible in a code review: nothing throws,
|
|
|
|
|
* the build is green, and the control is simply dead to a first-time user.
|
|
|
|
|
*
|
|
|
|
|
* Same shape of problem as `pos-inline-handlers.test.ts`: assert the
|
|
|
|
|
* affordance exists rather than trusting that it was left in place.
|
|
|
|
|
*/
|
|
|
|
|
const POS_APP = readFileSync(join(process.cwd(), "public", "pos", "pos-app.js"), "utf8");
|
|
|
|
|
const POS_HTML = readFileSync(join(process.cwd(), "public", "pos", "pos.html"), "utf8");
|
|
|
|
|
|
|
|
|
|
describe("POS cart quantity is directly settable", () => {
|
|
|
|
|
it("renders the quantity as a button, not an inert div", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/<button class="v"[^>]*onclick="window\.__pos\.openEditLine\(/);
|
|
|
|
|
expect(POS_APP).not.toMatch(/<div class="v">\$\{qty\}<\/div>/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("gives the quantity button an accessible label", () => {
|
|
|
|
|
// An unlabelled button reads as just "7" to a screen reader, which is
|
|
|
|
|
// no better than the div it replaced.
|
|
|
|
|
expect(POS_APP).toMatch(/aria-label="Set quantity for /);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the steppers as +/- only", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',-1\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',1\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("exposes openEditLine on the global the button calls", () => {
|
|
|
|
|
// The inline handler guard proves the name is reachable; this proves the
|
|
|
|
|
// specific member the button reaches for exists.
|
|
|
|
|
expect(POS_APP).toMatch(/window\.__pos\s*=\s*\{[^}]*openEditLine/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still opens the sheet on long-press", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/startLongPress\(line\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/openEditLine\(id\)/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS cart long-press does not hijack the quantity controls", () => {
|
|
|
|
|
/** The pointerdown listener body, up to its options argument. */
|
|
|
|
|
const pointerdownHandler = () => {
|
|
|
|
|
const m = POS_APP.match(
|
|
|
|
|
/addEventListener\('pointerdown'[\s\S]*?\{ passive: true \}\);/,
|
|
|
|
|
);
|
|
|
|
|
expect(m, "pointerdown handler not found").not.toBeNull();
|
|
|
|
|
return m![0];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("bails out of the pointerdown handler for .qty and .rm", () => {
|
|
|
|
|
// Holding "+" to ring up bulk used to pop the edit sheet open at 600ms,
|
|
|
|
|
// mid-tap. The steppers and the new quantity button each handle their
|
|
|
|
|
// own tap, so they must not start the row long-press.
|
|
|
|
|
expect(pointerdownHandler()).toMatch(/closest\('\.qty, \.rm'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("bails BEFORE starting the timer, not after", () => {
|
|
|
|
|
// Order matters: an early `return` leaves no pending timer to fire.
|
|
|
|
|
const handler = pointerdownHandler();
|
|
|
|
|
const guardAt = handler.indexOf("closest('.qty, .rm')");
|
|
|
|
|
const startAt = handler.indexOf("startLongPress(line)");
|
|
|
|
|
expect(guardAt, "guard not found in handler").toBeGreaterThan(-1);
|
|
|
|
|
expect(startAt, "startLongPress not found in handler").toBeGreaterThan(-1);
|
|
|
|
|
expect(guardAt, "guard must come before startLongPress").toBeLessThan(startAt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still falls through to the row long-press for a tap on the row body", () => {
|
|
|
|
|
// The guard must not disable the long-press gesture entirely.
|
|
|
|
|
expect(pointerdownHandler()).toMatch(/startLongPress\(line\)/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS discount trigger costs no footer height", () => {
|
|
|
|
|
it("sits inside the Subtotal row rather than its own row", () => {
|
|
|
|
|
// The standalone .discount block was ~54px of always-on footer
|
|
|
|
|
// (12px padding + a 42px min-height button). The trigger now rides
|
|
|
|
|
// inside the existing Subtotal row, so it adds nothing vertically.
|
|
|
|
|
expect(POS_HTML).not.toMatch(/class="discount"/);
|
feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
Three changes to the POS sale pipeline, all in one commit because
pos-service.ts / types.ts / pos-app.js are each touched by all three and
splitting them would only produce intermediate broken states.
VAT is now charged on top, not backed out
Catalogue prices are ex-VAT: `selling_price` is derived as
`cost_price x markup` from the supplier's ex-VAT cost, with no VAT
uplift anywhere (verified — median selling/cost across the catalogue is
exactly 1.8). The till nevertheless treated them as VAT-inclusive and
recovered the net with `total / 1.15`, so every sale was recorded with
an understated net and an understated VAT line.
An R100 ex-VAT line now rings up as R115. The rate comes from the till's
letterhead, read server-side rather than accepted off the wire, and is
stored per sale so a later letterhead rate change cannot rewrite what
was charged. All 3,623 catalogue prices are untouched.
Ad-hoc "custom" lines
A cashier can now ring up something that was never a catalogue item —
"bolt cutting" at R20 agreed at the counter — via a new
"+ Add a custom item" composer. The line prints on the slip and lands in
pos_sale_items, but carries no stock_item relation and takes no stock
movement: no ledger row, no current_qty decrement, no catalogue entry.
pos_sale_items.stock_item is no longer required at the schema level and
an explicit `is_custom` flag marks the line, rather than inferring it
from a blank relation — so "how much did we make on bolt cutting this
month" is a plain query, and a future stock-less line type cannot be
confused with a corrupt one.
Custom lines are refused on account sales: those raise a delivery note
the customer signs for and feed their statement, so an ad-hoc charge
must not slip onto one. Enforced server-side, not just in the UI.
Discounts are now 20% or 40%, nothing else
The R-off / %-off pills and the free-typed number box are gone. The
server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses
anything outside it rather than coercing it, so a stale till cannot
invent a discount. The discount lands on the ex-VAT subtotal, making
"20% off" a true 20% off both the net and the VAT on it.
Two defects found while doing this
pos_sales was missing six fields that checkoutSale has been writing all
along (gross_subtotal, gross_vat, gross_total, discount_amount,
discount_kind, discount_input). PocketBase accepts unknown fields
without error and silently drops them, so all 192 historical sales lack
them and a discounted sale reprinted as though no discount had been
applied. Added, plus vat_percent.
History also passed gross_total (VAT-inclusive) where the receipt
derives the gross VAT from an ex-VAT base — a reprint double-counted
VAT. Corrected.
Verification: 3860 tests pass (14 new); typecheck, lint and production
build clean; live PocketBase probe confirms a pos_sale_items row with
stock_item="" and is_custom=true round-trips, with the probe removed
afterwards.
2026-10-12 20:54:09 +00:00
|
|
|
expect(POS_HTML).toMatch(
|
|
|
|
|
/<div class="totals">\s*<span>Subtotal[^<]*\s*<button class="discount-trigger"/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("offers exactly the two fixed discount tiers, with no free-text input", () => {
|
|
|
|
|
// The tiers replaced the old R-off / %-off pills + number field.
|
|
|
|
|
// A free-typed discount is what we're deliberately removing, so its
|
|
|
|
|
// absence is the thing worth pinning — an input creeping back in
|
|
|
|
|
// would let a cashier invent an arbitrary discount again.
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discTier20"[^>]*onclick="setDiscountTier\(20\)"/);
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discTier40"[^>]*onclick="setDiscountTier\(40\)"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/id="discInput"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/setDiscountKind|applyDiscount/);
|
fix(pos): make cart quantity editable, and fix the sheet that hid it
Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.
- the quantity is now a real button that opens the Edit line sheet,
with an aria-label and a 44px touch target (it was 30px, too narrow
to even show "800")
- holding +/- no longer pops the sheet open mid-tap: the pointerdown
handler bails for .qty/.rm before starting the timer
- the sheet is quantity-only. The Unit price field is gone on request;
a mis-keyed price is expensive and easy to miss. The per-line
override it fed is removed with it — pos-service.ts only ever read
unit_price, the "price edited" label and the sale record's `edited`
flag were unreachable, and nothing else could set an override,
including a cart reload (persistCart saves only {id, qty, p})
The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.
Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #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. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.
Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.
sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps every id the discount JS depends on", () => {
|
|
|
|
|
// paintDiscount() and toggleDiscount() look these up by id; renaming any
|
|
|
|
|
// of them throws on drawer open, which would break the till.
|
|
|
|
|
for (const id of ["discountTrigger", "discountTriggerLabel", "discountBody"]) {
|
|
|
|
|
expect(POS_HTML, `missing #${id}`).toMatch(new RegExp(`id="${id}"`));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops the old full-width button styling", () => {
|
|
|
|
|
expect(POS_HTML).not.toMatch(/\.discount-trigger\{[^}]*width:100%/);
|
|
|
|
|
expect(POS_HTML).toMatch(/\.discount-trigger\{[^}]*border:0/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the applied-discount totals line as the always-on indicator", () => {
|
|
|
|
|
// The trigger is a small link now; the red totals row is what proves a
|
|
|
|
|
// discount is live when nobody is looking at the footer.
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discountLine"/);
|
|
|
|
|
expect(POS_HTML).toMatch(/class="totals discount-line"/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS Edit line sheet is actually visible when opened", () => {
|
|
|
|
|
// This is the bug that hid manual quantity entry entirely: #editSheet is
|
|
|
|
|
// a SIBLING of #editBackdrop, but the CSS revealed it with
|
|
|
|
|
// `.edit-backdrop.show .edit-sheet` — a descendant selector that cannot
|
|
|
|
|
// match a sibling. The sheet stayed at opacity:0 while the backdrop went
|
|
|
|
|
// opaque, so long-pressing a cart row showed a grey screen with nothing
|
|
|
|
|
// on it. Nothing threw, the build was green, and the feature looked
|
|
|
|
|
// simply absent.
|
|
|
|
|
it("reveals the sheet with its own .show rule, not a descendant of the backdrop", () => {
|
|
|
|
|
expect(POS_HTML).toMatch(/^\.edit-sheet\.show\{/m);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/^\.edit-backdrop\.show \.edit-sheet/m);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stacks the sheet above the backdrop", () => {
|
|
|
|
|
const backdrop = POS_HTML.match(/\.edit-backdrop\{[^}]*z-index:(\d+)/)?.[1];
|
|
|
|
|
const sheet = POS_HTML.match(/\.edit-sheet\{[^}]*z-index:(\d+)/)?.[1];
|
|
|
|
|
expect(backdrop, "backdrop z-index not found").toBeDefined();
|
|
|
|
|
expect(sheet, "sheet z-index not found — it would render under the backdrop").toBeDefined();
|
|
|
|
|
expect(Number(sheet)).toBeGreaterThan(Number(backdrop));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the sheet anchored to the viewport, not the document", () => {
|
|
|
|
|
// position:absolute resolved against an unpredictable ancestor.
|
|
|
|
|
expect(POS_HTML).toMatch(/\.edit-sheet\{position:fixed/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still opens and closes both elements from JS", () => {
|
|
|
|
|
// openEditLine/closeEditLine add .show to the backdrop AND the sheet —
|
|
|
|
|
// that part was always right, which is why the bug looked like a
|
|
|
|
|
// missing feature rather than a broken one.
|
|
|
|
|
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.add\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editSheet'\)\.classList\.add\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.remove\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editSheet'\)\.classList\.remove\('show'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps them siblings so a tap on the sheet does not close it", () => {
|
|
|
|
|
// #editBackdrop carries onclick="closeEditLine()". Nesting the sheet
|
|
|
|
|
// inside it — the "obvious" fix — would make every tap on Save bubble
|
|
|
|
|
// to the backdrop and dismiss the dialog first. The backdrop must
|
|
|
|
|
// therefore stay an empty, immediately-closed element.
|
|
|
|
|
const backdropTag = POS_HTML.match(/<div class="edit-backdrop"[^>]*>/)?.[0];
|
|
|
|
|
expect(backdropTag, "edit-backdrop not found").toBeDefined();
|
|
|
|
|
expect(backdropTag).toMatch(/id="editBackdrop"/);
|
|
|
|
|
const after = POS_HTML.slice(
|
|
|
|
|
POS_HTML.indexOf(backdropTag!) + backdropTag!.length,
|
|
|
|
|
);
|
|
|
|
|
expect(after.startsWith("</div>"), "backdrop must close immediately").toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS Edit line popup is quantity-only", () => {
|
|
|
|
|
// Requested: no price editing from the till. The override it used to set
|
|
|
|
|
// was also never persisted (persistCart saves only {id, qty, p}), so it
|
|
|
|
|
// was a session-only thing that produced a sale record the server
|
|
|
|
|
// ignored. Removing it is a deletion, not a feature loss.
|
|
|
|
|
it("has no price field in the sheet", () => {
|
|
|
|
|
expect(POS_HTML).not.toMatch(/id="editPrice"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/Unit price/);
|
|
|
|
|
expect(POS_APP).not.toMatch(/getElementById\('editPrice'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the quantity field", () => {
|
|
|
|
|
expect(POS_HTML).toMatch(/id="editQty"/);
|
|
|
|
|
expect(POS_APP).toMatch(/getElementById\('editQty'\)\.value = String\(line\.qty\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes the override mechanism entirely", () => {
|
|
|
|
|
// Nothing else could set it — not a cart reload, not addToCart — so
|
|
|
|
|
// leaving the field in place would be dead code implying the till can
|
|
|
|
|
// still change a price.
|
|
|
|
|
expect(POS_APP).not.toMatch(/unitPriceOverride/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/price edited/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still sends a price, and it is the shelf price", () => {
|
|
|
|
|
// /checkout requires unit_price; dropping the override must not drop
|
|
|
|
|
// the price itself.
|
|
|
|
|
expect(POS_APP).toMatch(/unit_price: lineUnitPrice\(\{p,qty\}\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/selling_price: lineUnitPrice\(\{p,qty\}\)/);
|
|
|
|
|
expect(POS_APP).toMatch(
|
|
|
|
|
/function lineUnitPrice\(line\)\{\s*return Number\(line\.p\.selling_price/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("no longer sends the unused `edited` flag", () => {
|
|
|
|
|
// pos-service.ts never read it — only `unit_price` matters.
|
|
|
|
|
expect(POS_APP).not.toMatch(/\bedited:/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS service worker cache tracks shell edits", () => {
|
|
|
|
|
it("is past v30, since pos-app.js and pos.html are served cache-first", () => {
|
|
|
|
|
const v = POS_HTML; // placeholder to keep the import honest
|
|
|
|
|
expect(v).toBeDefined();
|
|
|
|
|
const sw = readFileSync(join(process.cwd(), "public", "pos", "sw.js"), "utf8");
|
|
|
|
|
const m = sw.match(/const CACHE = "pos-v(\d+)"/);
|
|
|
|
|
expect(m, "sw.js CACHE constant not found").not.toBeNull();
|
|
|
|
|
expect(Number(m![1])).toBeGreaterThanOrEqual(33);
|
|
|
|
|
});
|
|
|
|
|
});
|