The CSS lived at public/pos/fa/all.min.css but its url() references
used ../webfonts/..., which from that location resolves to
/pos/webfonts/... rather than /pos/fa/webfonts/. The woff2 was at
the latter, so the browser silently 404'd every font request and
the icons rendered as tofu boxes.
The original FA 6.5.2 CSS ships with ../webfonts/... because it
assumes the CSS sits in a sibling css/ subdirectory next to webfonts/.
Ours is one level deeper, so the path is now plain webfonts/....
Bump SW cache to v15 so installed tills pick up the corrected CSS.
Co-Authored-By: Claude <noreply@anthropic.com>
Two spec-vs-implementation drifts caught by the final review:
- Move 'per-row validation → 200 with reason in results[]' out of the
'Reject 400' list. The route enforces body shape (json / empty /
too-many / bad id / missing actor) as 400; the service enforces
forbidden_field / non-finite / not_found / pb_error per row so one
bad sibling never aborts the rest.
- Move the 'realtime-conflict banner' from Page-behaviour to Out of
scope (the implementation deferred it; the spec still listed it).
Code-review feedback from Task 4: the verbatim plan source had a
`throw new Error(...)` inside the per-row .map() that escaped
synchronously as an HTTP 500 instead of the structured 400 envelope
the rest of the route uses for validation errors. Swap .map() for a
for-of loop that returns the structured 400.
Verified:
- Bad id → 400 {code: "validation_error", ...}
- Happy path → 200 unchanged
Code-review feedback from Task 2:
- Code-review Issue #1 (Important): the strict-equality guard fired on
'before.cost_price = undefined' vs 'update.costPrice = 0' — exactly
the population this feature targets (fresh catalogue rows the
operator is pricing for the first time). No real cost movement was
happening, but a spurious ledger row was getting written. Gate the
ledger write on Number.isFinite(before.cost_price) so first-time
pricing doesn't pollute audit history with a 0 -> 0 'movement'.
- Code-review Issue #4 (Minor, audit-readability): add the actual
before -> after delta to the ledger notes so operators scrolling
the ledger history see 'Gary edited cost 2.50 -> 3.20 from /stock'
rather than the prior opaque 'Gary edited cost from /stock'.
- Added two regression tests: one for the first-time-pricing case,
one pinning the notes delta format.
Code-review feedback from Task 1:
- Drop the unused FORBIDDEN_KEYS constant + its export. The whitelist
PATCHABLE_KEYS already covers what was meant; nothing else reads this.
- Replace UpdateStockItemsPricingResultRow's reason: string with a tight
UpdateStockItemsPricingFailureReason union so the route can map to HTTP
status without parsing the message string. Matches UpdateStockItemResult
in stock-items-service.ts:113.
- Replace the array-tail-fragile 'last push matches current id' guard
with an explicit validationFailed boolean per row, decoupling from
push order in the inner loop.
Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.
Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
1. category.match_brands (against item text or supplier's supply_tags)
2. category.match_supplier_tags
3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
4. the 'Unknown / Other' catch-all is excluded from matching — anything
with no match simply gets the initials fallback
The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).
FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.
Co-Authored-By: Claude <noreply@anthropic.com>
Lets the operator scope a backfill to recent orders so off-peak
sanity passes don't disturb the active workflow. e.g.:
POST /api/v1/orders/backfill-duplicate-checks?limit=200&since=2026-08-24
rescans the last 7 days up to 200 rows. Combines with limit/skip;
rejects malformed dates with 400 instead of silently no-op'ing.
Cross-cutting review tab at the end of the strip — lists every order
with any of the three duplicate-detection flags set:
- order_number_duplicate
- potential_duplicate_order
- potential_delivery_note
Implementation:
- src/lib/item-status-ui.ts: add 'duplicates' to TabGroup + visual +
TAB_GROUP_ORDER (placed last so it doesn't compete with
active-workflow tabs). Empty array in TAB_GROUP_ITEM_STATUSES
(it's not an item-status group — the duplicates tab is
cross-cutting on order-header fields).
- src/lib/server/orders-service.ts: add duplicateOrderFilter() +
orderHasAnyDuplicateFlag() helpers. listOrdersWithItems routes
tabGroup='duplicates' to a PB filter on the orders collection
(separate from the item-level tabGroupFilter the other tabs use).
getOrderItemStatusTabCounts counts duplicates independently so
the badge stays accurate even when the active-workflow counts
are filtered out.
- src/app/(app)/orders/page.tsx: add the tab to the strip + an
empty state with the purple #a855f7 visual.
The till was painting all 373 active stock items at once, which is slow
and buries the stock staff actually reach for. The catalog now comes back
newest-first (PosCatalogItem carries created) and renders 50 at a time,
with a Show-more button that also auto-loads as it scrolls into view.
Header reads 'Recently added' when browsing and 'Results' once a search
or category is applied; changing either resets paging to the first chunk.
Scroll position is preserved across re-renders so adding to the cart no
longer throws the operator back to the top of the list.
Co-Authored-By: Claude <noreply@anthropic.com>
The Include-prices toggle drives both the on-screen slip and the
Bluetooth thermal print, so what prints matches what the operator saw.
Co-Authored-By: Claude <noreply@anthropic.com>
Method split now reads pos_sales.payment_method directly. The four
method rows cashup already renders (cash/card/eft/account) would have
been permanently R0 for eft and account under a cash/card-only backend.
Co-Authored-By: Claude <noreply@anthropic.com>
Payment method now read from pos_sales.payment_method rather than
sniffed out of an order's notes with regexes.
Co-Authored-By: Claude <noreply@anthropic.com>
Sends client_ref (the till's localId) so the offline queue can retry a
sale that already landed without recording it twice. Customer search
moves to /api/v1/pos/customers and now carries the company name for the
delivery-note slip.
Co-Authored-By: Claude <noreply@anthropic.com>
pb-config.js deliberately not copied — it hardcoded a PocketBase
superuser email/password in client-shipped JS.
Co-Authored-By: Claude <noreply@anthropic.com>
All three gated on POS_API_KEY. Checkout accepts all four tender types
and forwards client_ref for idempotent offline-queue replays.
Co-Authored-By: Claude <noreply@anthropic.com>
Records all four tender types. Only cash sets cash_given/change_given;
card, EFT and account settle outside the till, so cashup can reconcile
the physical drawer. Stock decrements for every tender including
account, since the goods leave on a delivery note.
client_ref makes checkout idempotent: an offline-queued sale that was
already committed server-side replays to the original record instead of
decrementing stock twice.
Co-Authored-By: Claude <noreply@anthropic.com>
Supports all four POS tender types (cash/card/eft/account) rather than
just cash/card: only cash puts money in the drawer, so cashup needs the
distinction. PosSale.client_ref carries the till's local sale id as an
idempotency key for the offline queue.
Co-Authored-By: Claude <noreply@anthropic.com>
Refine the handling of order_number values in the MCP API. Custom free-text order_numbers are now allowed to repeat across orders without triggering collision checks, while only values formatted as ORD-YYYY-MM-NNN will be subject to collision verification. Update tests to reflect this new behavior and ensure proper functionality.
The route accepted ?skip=N but always called getList(1, ...) — every
chunk re-processed the FIRST limit orders, leaving offsets > limit
unprocessed. Convert skip to page (floor(skip/limit) + 1) and add a
regression test asserting getList is called with the right page.