diff --git a/docs/superpowers/specs/2026-08-30-wfsales-pos-design.md b/docs/superpowers/specs/2026-08-30-wfsales-pos-design.md new file mode 100644 index 0000000..e15a1fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-wfsales-pos-design.md @@ -0,0 +1,183 @@ +# WFSALES POS — Design + +## Problem + +An existing POS mockup (`pos.html`, `receipt.html`, `history.html`, `cashup.html`, +`pos-app.js`, service worker — built outside this repo, in Open Design) already +implements catalog browsing, cart, cash/card payment, an offline sale queue, +and Bluetooth thermal-printer receipt printing. It is not safe or correctly +wired to go live: + +1. `pb-config.js` hardcodes a PocketBase **superuser** email/password in plain + text inside a static, client-shipped JS file. Anyone who can view the page + source gets full admin access to the entire PocketBase instance — every + collection, not just stock. +2. The catalog reads from a `pos` collection that is a stale, disconnected + duplicate of `price_list`. Confirming a sale never decrements + `stock_items.current_qty` or writes to `stock_ledger` — stock levels don't + move when a sale happens, which is the core requirement. +3. The "account sale" path posts to a `delivery_note_items` collection that + does not exist in the PocketBase schema, so it silently fails today. + +Separately (confirmed by codebase research, out of scope for this project): +stock-quantity writes are already fragmented across 4 independent call sites +in the main app (`stock-surplus.ts`, `stock-out.ts`, the `/api/v1/stock/fulfill` +route, and the MCP `stock_items.adjust` handler), and delivery notes in the +main app never touch `stock_items` at all. This project does not attempt to +unify those — it adds one more clean, self-contained stock-write path scoped +to POS checkout only. + +## Goals + +- Counter staff can browse live stock, build a cart, and take a cash or card + payment on a device running this app. +- Confirming a sale correctly and atomically-as-possible decrements + `stock_items.current_qty` and records a `stock_ledger` entry, so stock + shown in the POS and in the main WFDASH app stays accurate. +- No PocketBase credentials of any kind are ever shipped to the client. +- Existing UX (catalog/cart/payment screens, offline queue, Bluetooth thermal + printing) is preserved and reused, not rewritten. +- Account (pay-later) sales can print a delivery-note-style thermal slip + (with or without prices) referencing the customer/company, without creating + a new order/delivery-note record in the main workflow. + +## Non-goals + +- Unifying the 4 existing divergent stock-adjustment code paths in the main + app. +- Making delivery notes in the main WFDASH app decrement stock. +- Per-cashier login/PIN or staff attribution — this is a shared till. +- Real payment gateway integration — card payments are recorded as "paid via + the physical card machine, no change due"; the POS does not process cards + itself. + +## Architecture + +**Frontend** stays the existing static PWA, relocated into +`public/pos/` in WFDASH-WEBAPP and served at `/pos/*`: +- `index.html`, `pos.html`, `receipt.html`, `history.html`, `cashup.html`, + `pos-app.js`, `theme.js`, `sw.js`. +- No React/Next.js page rewrite. The offline-first behavior (IndexedDB cart + and sale queue, service worker asset caching, Web Bluetooth thermal + printing) already works client-side and has no SSR dependency. +- `pb-config.js` is replaced with a small config module holding only: the + base URL of this app's own `/api/v1/pos/*` routes, and a `POS_API_KEY` + value (set once at install time, stored in `localStorage`, never a + PocketBase credential of any kind). + +**Backend** follows this repo's existing service-layer convention +(`AGENTS.md`: new features → `src/lib/server/-service.ts` + thin +`src/app/api/v1///route.ts`): +- `src/lib/server/pos-service.ts` — business logic: list catalog, checkout + (create sale + sale items + stock ledger/qty writes), list sales for + history/cashup. +- `src/app/api/v1/pos/catalog/route.ts` (GET) — active `stock_items`. +- `src/app/api/v1/pos/checkout/route.ts` (POST) — confirm a sale. +- `src/app/api/v1/pos/sales/route.ts` (GET) — sales for history/cashup, with + a date-range filter. +- All three routes require a `POS_API_KEY` header, checked server-side + against an env var, following the same shape as the existing + `agent-auth.ts` / `AGENT_API_KEY` pattern. On success, routes use + `pocketbase-admin.ts` for the actual PocketBase access — real credentials + never leave the server. + +## Data model + +New PocketBase collections (created by the user in PB admin — this repo +never runs schema migrations against the live instance): + +- **`pos_sales`**: `sale_number` (text), `payment_method` (select: + `cash`, `card`), `cash_given` (number, nullable), `change_given` (number), + `subtotal` (number), `vat` (number), `total` (number), `customer` + (relation → `customers`, optional), `synced_at` (date). +- **`pos_sale_items`**: `sale` (relation → `pos_sales`), `stock_item` + (relation → `stock_items`), `sku` (text), `description` (text), `qty` + (number), `unit_cost` (number), `unit_price` (number). + +Existing collections reused as-is: +- `stock_items` — catalog source and quantity target (`current_qty`). +- `stock_ledger` — one row per sale line on checkout. Needs one new + `reason` select value, e.g. `pos_sale` (per `AGENTS.md`: select values are + added in PB admin first, by the user, before this code path can use them). +- `customers` — for account-sale customer lookup (existing `/pos` search + already queries this). + +The `pos` collection (stale price_list duplicate) is no longer read by the +app after this change. It is left in place (not deleted) — deleting it is a +separate decision for the user to make later. + +## Data flow + +1. **Catalog load**: POS calls `GET /api/v1/pos/catalog`, which returns + active `stock_items` records (id, sku, description, category, unit, + `current_qty`, `selling_price`, `cost_price`). Cached client-side in + IndexedDB exactly as the existing mockup does today, for offline browsing. +2. **Checkout**: POS posts `{ items: [{stock_item, qty, unit_price, ...}], + payment_method, cash_given, customer? }` to `POST /api/v1/pos/checkout`. + `pos-service.ts`: + - Re-reads each `stock_item` server-side and rejects the request if + `current_qty` for a line is insufficient (no negative-stock sales). + - Creates one `pos_sales` record and its `pos_sale_items` rows. + - For each line: creates a `stock_ledger` row (`reason: pos_sale`, + negative `change_qty`) and updates `stock_items.current_qty`. This is + one clean, self-contained helper — not shared with or refactored from + the 4 existing divergent stock-write call sites in the main app. + - Returns the created sale (for the receipt screen) or an error. +3. **Offline behavior**: unchanged from the existing mockup — if the + checkout call fails (offline, network error, server error), the sale is + queued locally (IndexedDB, falling back to `localStorage`) and the UI + still proceeds to the receipt screen immediately (never blocks on + network). The existing `flushQueue()` retry-on-reconnect logic is kept. + Stock is only decremented once a queued sale successfully lands on the + server, so a sale that never syncs never affects stock, and a sale is + never double-decremented. +4. **Account sales / delivery-note printing**: no `orders` or + `delivery_notes` record is created by the POS. `receipt.html` gets a + second print template (thermal, via the existing Web Bluetooth flow) that + renders a delivery-note-style slip pulling the company/customer name from + the `customer` relation on the sale, with a toggle to include or omit + prices. This is purely a print format on top of the `pos_sales` record + already created — not a new workflow record in the main app. +5. **History / Cashup**: `history.html` and `cashup.html` switch from + querying `orders`/`order_items` (today, unfiltered — showing all orders, + not just POS sales) to querying `pos_sales`/`pos_sale_items` via + `GET /api/v1/pos/sales?from=&to=`. + +## Error handling + +- Checkout validates stock availability server-side before writing anything; + insufficient stock returns a 4xx with enough detail for the UI to show + which line is short. +- If the `pos_sales`/`pos_sale_items` write succeeds but a `stock_ledger`/ + `stock_items` write fails partway through a multi-line sale, the response + indicates partial failure (sale exists, some lines may not have decremented + stock) so it can be reconciled manually — the existing pattern in + `stock-out.ts`/`stock-booking-service.ts` for compensation-needed errors is + followed for consistency, without adopting their code directly. +- All `/api/v1/pos/*` routes reject requests missing or mismatching + `POS_API_KEY` with 401, before touching PocketBase. + +## Testing + +- `pos-service.test.ts`: checkout creates correct `pos_sales`/ + `pos_sale_items`/`stock_ledger` rows and decrements `current_qty` + correctly; rejects checkout when stock is insufficient; catalog list only + returns active items. +- Route tests for `catalog`, `checkout`, `sales`: 401 without a valid + `POS_API_KEY`, happy-path shape, following this repo's existing + `route.test.ts` conventions (see `orders/backfill-duplicate-checks/route.test.ts` + for the pattern). +- Manual verification of the frontend (catalog browse, cart, cash/card + checkout, receipt + thermal print, offline queue/flush, history, cashup) + against the new endpoints, since the frontend itself is plain JS/HTML with + no existing test harness in this repo. + +## Open items for the user (PocketBase admin, manual) + +- Create `pos_sales` and `pos_sale_items` collections with the fields above + and appropriate API rules (no public access; only the server's superuser + credentials write to them). +- Add `pos_sale` as a valid `reason` select value on `stock_ledger`. +- Set a `POS_API_KEY` value as an env var for the WFDASH-WEBAPP deployment, + and configure the same value into the POS device's local config at + install time.