Skip to content

Roadmap

wiki-sync edited this page Jul 27, 2026 · 11 revisions

Roadmap

Sequenced so that every milestone ends with something you can actually run. No milestone is "build the service layer." The ordering principle: the riskiest and most expensive-to-change decisions get exercised by real code first.

M0 — Skeleton that boots

  • infra/docker-compose.yml: postgres:18-alpine, volume, healthcheck.
  • backend/: Laravel 13, Sanctum, Postgres connection, /api/v1/health.
  • The 04-backend-conventions.md skeleton: app/Actions, app/Domain, app/Exceptions/Domain with the DomainException base and its render hook, and the directory layout. /api/v1/health is built as a real action — controller, action, resource — so the very first endpoint sets the shape every later one copies.
  • frontend/web/: Vite 8 + React 19 + TS 7, hits /health, renders the result.
  • git init, .gitignore, CI running pest + tsc -b.
  • CLAUDE.md documenting how to run all of it.

Done when: docker compose up, two dev servers, and a browser page that says the API and database are alive.

Status: complete. Notes from actually building it, for whoever hits the same walls:

  • postgres:18 moved the recommended mount to /var/lib/postgresql (not .../data); mounting the old path makes the container restart-loop on first boot.
  • Laravel's phpunit.xml ships pointing at in-memory SQLite. Repointed at real Postgres per the testing rule above — this is deliberate, not an oversight.
  • The Vite template sets erasableSyntaxOnly, which forbids constructor parameter properties. Type syntax must never emit runtime code.
  • The framework's own exceptions (404, 405, validation) needed explicit mapping into the error envelope; only handling DomainException leaves Laravel's default shape leaking through, which breaks the one-code-path promise in 03-api.md.

M1 — Money primitives

Before any schema. These are pure integer functions with no I/O, they're where the expensive bugs live, and they are the foundation everything else computes on.

  • Money value object (int cents; no float constructor exists — not "discouraged", absent).
  • Tax: exclusive and inclusive extraction, per-line, half-up.
  • Percent and fixed discounts.
  • Change calculation.
  • Split allocation with deterministic remainder (1000 / 3 → 334, 333, 333; earliest absorbs).
  • Cents branded type + formatter on the frontend.

Done when: the unit suite is green, including the penny-allocation property test asserting parts always sum to the whole.

Why first: every later milestone calls this code. A rounding bug found here costs an afternoon; found after go-live it costs a reconciliation.

Status: complete. app/Domain/Money/Money, Quantity, TaxRate, Discount, Tender — plus frontend/web/src/lib/money.ts. 132 backend tests, 29 frontend.

Decisions taken while building, worth knowing before M3 calls this code:

  • One rounding primitive. Money::fraction(n, d) rounds half away from zero, and tax, discounts and fractional quantities are all expressed through it. One place a cent can be created or destroyed; one place to test.
  • Money::parse() exists but no float constructor does. Admins type prices, so a string parser is necessary; it uses string arithmetic, and rejects a third decimal rather than rounding it, because silently discarding a digit is losing money quietly.
  • Discounts clamp to the base. A $10 discount on a $5 item takes $5, never −$5. Unclamped, a "generous" discount turns a sale into a payout — a fraud surface, not a rounding detail.
  • Tender separates applied from tendered, which is why insufficient_tender (422) now exists in 03-api.md. Tendering less than the amount applied is impossible; underpaying the order is just a partial payment and perfectly legal.
  • Overflow fails with a reason. PHP promotes integer overflow to float — the exact thing this code exists to prevent. Unreachable with real money ($10M at 100% is four orders of magnitude below PHP_INT_MAX), but guarded rather than assumed.

M2 — Schema + auth

  • All migrations from 02-data-model.md, including the partial indexes and checks.
  • Models, factories, seeders (two locations, a few products, staff at each role).
  • Register enrollment; device tokens.
  • Staff PIN login, rate limiting, the PIN-collision check on set.
  • spatie/laravel-permission per 05-rbac.md: publish and edit the migrations for uuid team/morph keys, enable teams on location_id, seed the permission catalog and roles, set team context in EnsureStaffSession.
  • config/pos.php per 04-backend-conventions.md.

Done when: you can enroll a register, log in with a PIN, and be refused when the permission is missing. Tests cover the PIN collision check, the lockout, and the per-location roles test (same user, two registers, different can()).

Status: complete. 191 backend tests. 40 tables, 28 check constraints, 6 partial indexes, all verified to bite against real Postgres.

What building it changed, and what to know before M3:

  • Admin cannot be a spatie role. 05-rbac.md claimed a null team key makes a role global; it doesn't — it makes a role definition shared, while every assignment still pins to one location (the pivot's team column is in its primary key, so NOT NULL). Admin is now users.is_admin + Gate::before, which is spatie's own super-admin pattern. The doc is corrected.
  • PIN login needed a lookup index. Bcrypt is salted, so login would have to check every candidate's hash — measured at 225ms each, twenty staff is a 4.5-second login. A keyed pin_lookup (HMAC with APP_KEY) makes it one indexed query; pin_hash stays the authority.
  • Sanctum had the same uuid problem as spatie (morphsuuidMorphs), which no document predicted.
  • StaffLogin must set the permission team context itself — login runs before the middleware that normally does. Without it, the response's permission list is silently empty rather than wrong-and-loud. Exactly the failure 05-rbac.md warns about.
  • Anything reading role assignments must query model_has_roles directly, never the roles() relation — that relation scopes to the current team, so it silently answers a different question. Bit both StaffDirectory and User::locationIds().
  • A constraint violation aborts the Postgres transaction, and RefreshDatabase wraps each test in one. A test can provoke one violation, and nothing after it.

M3 — The vertical slice

Scan a barcode, ring up an item, pay cash, get change, print a receipt.

  • Open shift with a float.
  • GET /catalog, barcode lookup.
  • Open order, add line (snapshots + stock decrement in one transaction).
  • Cash payment, change, auto-close on paid in full.
  • Receipt JSON from snapshots.
  • Register UI: scan → cart → tender → change → receipt.
  • Close shift, count, variance.

Done when: a real sale runs end-to-end in a browser and the drawer reconciles.

Why this is the milestone that matters: it exercises money, snapshots, stock locking, idempotency, shifts, and auth together. Everything after it is addition; everything before it is preparation. If the architecture is wrong, this is where it shows — while it's still cheap.

Status: complete. 255 backend tests, 29 frontend. A real sale runs scan → cart → cash → change → receipt in a browser, and shift close reconciles the drawer.

What building it changed, and what to know before M4:

  • Validation failures are 400 validation_failed, everywhere. Two briefs assumed 422 for a missing Idempotency-Key header; the envelope's actual split is 400 = the request is malformed, 422 = the request is well-formed but the domain refuses it. Tests must assert accordingly.
  • Eloquent never hydrates DB column defaults after create(). An Order created without explicit version/totals carries PHP nulls in memory even though Postgres wrote 0s. OpenOrder sets all six explicitly; any later action creating rows that lean on column defaults must do the same or ->refresh().
  • Postgres jsonb does not preserve object key order. An idempotency replay is content-identical but not byte-identical to the original response. Tests compare replays with toEqual, never toBe.
  • Two concurrent first uses of one idempotency key: both run, one commits, the loser hits the key's unique PK and 500s — but rolls back entirely, so "a replayed key charges once" holds. Accepted for v1; a retry after the 500 replays cleanly.
  • The two-connection stock concurrency test cannot run under RefreshDatabase — the second connection can't see uncommitted rows. A plain PHPUnit class in tests/Feature/ escapes Pest's uses() binding — subtle but deliberate; see ConcurrentSaleTest.
  • The register UI must keep a freshly-opened order in client state even when its first add-line fails (insufficient stock) — otherwise every retry opens another server-side order, and open orders block shift close. Related accepted gap: an abandoned empty open order still blocks close until M4 ships void-order; the register-side remedy today is ringing the next sale onto it.
  • Staff sessions genuinely end at shift closeCloseShift revokes the register's staff tokens; the UI returns to the PIN screen.
  • The seeder now prints a device token per register (paste into the SPA's setup screen) and seeds stock through StockLedger::receive, so the ledger invariant holds from row one.

M4 — Retail complete

  • Variants with options; per-location price overrides.
  • Discounts (line and order), supervisor gating.
  • Void line, void order, reopen.
  • Refunds with per-line restock.
  • Stock: adjustments, receiving, counts, movement history.
  • external_card driver.
  • Z-report.

Done when: a retail store could run a full day, including the parts of a day that go wrong — returns, voids, miscounts.

Status: complete. 346 backend tests, 35 frontend. A retail store can run a full day, including the parts that go wrong: voids, discounts, refunds with restock, a standalone card tender, stock corrections, and a shift close backed by a Z-report.

What building it changed, and what to know before M5:

  • The register moved mid-milestone, at the owner's direction: Vite SPA → Next.js 16 (app router) + TanStack React Query. The port kept the API client contract intact — one client boundary under a server shell, /api rewrites doing the same single-origin job the Vite dev proxy did. Next's built-in type-check can't drive TypeScript 7 (it misreads it as missing), so it's skipped; tsc --noEmit gates instead.
  • DiscountResolver review caught a real money bug before merge. Allocating an order-level discount across lines fed zero-base lines into the penny allocator, and its remainder distribution could push a line's discount past its own base — a negative line total. Fixed by keeping only positive-remaining-base lines in the ratio array plus a clamped overflow walk; line-level discount rows now resolve sequentially against each line's remaining base, not its original one.
  • Piecewise refunds needed the same exact-split discipline M1 built for payments. Refund amounts derive from qty fractions, and a line whose total doesn't divide evenly invented or lost a penny across several partial refunds — until the amount was also capped, with exhaustion taking the exact remainder. "Split sums exactly" turned out to apply to refunds too, not just tenders.
  • Voided orders keep their frozen totals. Recalculating them is deliberately skipped, and nothing sums a voided order's totals for reporting — the payments/refunds ledgers are the source of truth there, never the order row.
  • The Z-report has to be fetched before the shift close lands. Close revokes the register's staff sessions, so the close screen fetches the — already-final — running Z at mount, before the counted-cash round-trip that ends the session.
  • The register keeps the sale screen mounted while on Refunds or Close. Unmounting it stranded an in-progress order server-side with no UI path back: open orders block shift close, and voiding a line needs the order on screen.
  • external_card proved the driver seam. A new driver plus one validation rule, and zero changes to TakePayment, VoidPayment, or refunds.
  • The "money leaves" fraud-surface definition broadened. It was written around till cash; a stock adjustment moves sellable value out the same way, so 05-rbac.md now states the definition to cover both instead of treating stock.adjust as an exception.

M5 — Food service complete

  • Open tabs, table_ref, floor/tab list view.
  • Modifiers end-to-end: groups, min/max validation, price deltas, receipt display.
  • Split payments across tenders.
  • Transfer an order between staff.
  • Register UI mode switch (menu grid vs. scanner).

Done when: a cafe could run a lunch service: open a tab, add courses over an hour, split three ways.

Why after retail: food service is retail plus a longer open phase plus modifiers. The whole thesis in 00-overview.md is that this milestone adds screens, not tables. If it turns out to need a schema change, the thesis was wrong and we want to learn that with M4 already earning.

Status: complete. 387 backend tests, 78 frontend. scripts/e2e-lunch-service.sh runs a full lunch service against a freshly seeded stack: two tabs on two registers, modifiers including a repeated one, a fired course, a qty bump on that fired line, a transfer, a three-way split paid across cash and card, a forced-and-approved drawer variance, and a clean reconciling close.

What building it changed, and what to know before M6:

  • The thesis held. M5's entire schema cost is one new column (registers.mode, plus its own check) and one new constraint — a paired check on shifts.variance_approved_by/_at, columns that already existed, forward-declared nullable at M2. Zero new order-model tables. table_ref (M2), prep_state (M2, unused until now), and the whole order/line/payment lineage from M3–M4 needed no shape change at all, only new actions reading and writing the columns already there. The risk this milestone existed to retire — "food service needs a parallel model" — didn't materialize.
  • Split's exactness discipline is the same one M1 built for payments, applied per child. Every allocated column (qty in milli, line total, tax, modifiers, each discount row) runs through the earliest-absorbs-the-remainder allocator once, and children's totals are summed from those allocated parts afterward, never recomputed independently — recomputing 1/N of a tax would mint or lose pennies that the sum-of-parts approach can't. The original order is closed out voided, without restock — stock left the ledger when the lines were first added and the children inherit that claim, so restocking on split would double the stock and understate the sale.
  • Prep state deliberately carries no If-Match and bumps no version. A kitchen tapping "fired" or "ready" races nothing at the till — a version bump there would invalidate an in-flight tender for a reason the cashier can't see. The trade is a known, accepted one: SetLinePrepState is lock-free, so a prep update racing a same-line void can land after the void. Order-line financial writes still lock and version as before; only the coursing verb is exempt, because it isn't money.
  • The blind-count screen from M4 needed a correction, not an addition. M4's close screen showed the expected cash before the count, which lets a lazy cashier just retype the number back. M5's variance-approval flow only has teeth if the count is real, so the close screen now asks for the counted amount first and reveals expected/variance only after — the same UI, a different order of two fields, and a fix that belongs to M4's feature even though M5 is what surfaced the gap.
  • The idempotency-key invariant is stricter than the plan assumed, and better. The plan brief guessed a key could be scoped "per path" so the same key on two different endpoints would both execute. It can't: idempotency_keys.key is a bare primary key with no path or order in it, so reusing one anywhere in the system for a genuinely different request is 409 idempotency_key_reused, full stop. 01-architecture.md already documented the real shape; the plan brief was the thing that drifted, and this is the correction landing in the one place a client actually reads it.
  • Approving a variance from the register that just closed 401s, because closing revokes every staff session bound to that register. Not a bug: approval happens from a different register at the same location (the check is on location, not the specific terminal), or from the M6 back office once that exists. scripts/e2e-lunch-service.sh approves from the terminal that's still open for exactly this reason.
  • Decreasing a fired line's quantity shares its permission with voiding a sent line, decided inside UpdateLineQty rather than as a new named permission — shrinking a course the kitchen already started is the same fraud surface as pulling it off the ticket outright. Increasing needs no such gate.

M6 — Back office

  • Catalog CRUD; user management; location and register settings.
  • Sales reports (by day, category, user); stock and low-stock reports.
  • Audit log viewer.

Done when: an admin never needs psql.

Status: complete. 462 backend tests, 80 register-app tests, 80 back-office-app tests. scripts/e2e-admin-day.sh runs a full admin day against a freshly seeded stack: build a menu item from nothing (category, tax rate, product, variant, modifier group + modifiers, attach), hire a cashier, switch a till to food mode and reissue its device token (the old one is dead before the script's next line), ring a sale on the new token, reprice the sold variant from the back office and prove the paid order's receipt didn't move, read the same sale back through all three sales-report slices and the audit log, and close the shift clean.

What building it changed, and what to know before M7:

  • The M2 schema finally earned its keep. users.email/password_hash, the POST /registers/enroll admin-session precedent, and half the permission catalog (catalog.manage, user.manage, location.manage, register.enroll, audit.view) were forward-declared four milestones ago and sat unused until now. Nothing about them needed to change to carry the whole back office — the schema and the permission names were right the first time, which is the payoff for having named the fraud surface (05-rbac.md) before there was a screen sitting on top of it.
  • Archive-never-delete is the CRUD spine, not a policy bolted onto it. There is no DELETE route anywhere under /admin/* — a category, product, variant, modifier, discount, tax rate, location, or register is retired with PATCH { "is_active": false }. Deciding this once, in the first catalog task, gave every later entity (users, locations, registers) the same shape for free instead of relitigating "can we delete this" seven times.
  • Reports have two honest bases, and they're not required to reconcile. sales grouped by day/user is ledger-basis — summed from captured payments and refunds, money that actually moved. Grouped by category it's line-basis — summed from order lines, joined to the live catalog for a category name, which a report may do and a receipt never may. The resource's basis field says which is which, so the back office never implies a single number both slices would agree on when they have no reason to.
  • Per-location roles bit again, in exactly the shape M2 warned about. RoleAssignments had to read and write model_has_roles directly a second time — spatie's roles() relation still only answers "roles at the location I'm standing at," and a back-office write has no register to stand at in the first place. Same gotcha, second implementation, same fix; see CLAUDE.md.
  • A Postgres CHECK is evaluated after every statement, not once at commit. UpdateUser writes roles, then a PIN, then the plain columns, in that order, inside one transaction, because users_can_authenticate (email or PIN hash not null) would otherwise see an intermediate state — nulling the email before a PIN hash is on the row fails a constraint that the finished transaction would have satisfied. The CHECK doesn't know the transaction isn't done yet.
  • A resource that only exposes an attach relationship on some responses is an attach-blindness bug. AdminProductResource originally carried modifier_groups only where the caller had eager-loaded the pivot; a full-set-replace attach editor seeded from a response that omitted it would save back an empty set and detach every group the product actually had. The fix was a second, unconditionally-present field (modifier_group_ids), and the lesson generalizes past this one endpoint: any full-set-replace write needs its read side to expose the current set on every response, not just the ones that happened to eager-load it.
  • Reissuing a device token kills the old one inside the same transaction. POST /admin/registers/{id}/token deletes every existing personal-access token for that register and mints the replacement before either write commits, so there's no window where a lost terminal's old credential and its replacement are both live.
  • Admin-only back-office auth was a scope decision, not an oversight. /admin/login has no register and no device, so it has no team context for spatie's per-location roles to hang off — the same reason admin isn't a role at all. A read-only supervisor/bookkeeper tier is a named deferral, waiting on the first accountant who needs sales and audit visibility without order-void or user-management power; designing it now, with no real user to shape it around, would be guessing at the wrong problem.

M7 — Production

  • Deploy topology, TLS, backups (and a restore drill — an untested backup is a rumor).
  • Structured logging, error tracking, uptime alerting.
  • Load test at realistic lunch-rush concurrency.
  • Runbook: register won't connect, drawer won't reconcile, restore from backup.

Done when: it's live and someone other than us can operate it at 2am.

Status: complete. Scoped at the owner's direction to what containerizing actually needs — industry-standard Dockerfiles driven by a Makefile — plus the two pieces the production compose naturally carries: automatic TLS and a runnable restore drill. Monitoring, load testing, the runbook, and a registry/CD pipeline are named deferrals below, not gaps nobody noticed. make dev on a machine with nothing but Docker installed brings up the full stack — db, api, register, back office — hot-reloading against the working tree; make prod-up on a host with DNS serves both apps over TLS from one edge; make restore-drill proves a backup restores into a throwaway container. 462 backend / 80 register / 80 back-office tests — unchanged, since containerizing touched no application code — now run inside the stack itself via make test, and all three committed end-to-end scripts run against it via make e2e.

What building it changed, and what to know operating it:

  • Config is cached at boot, never baked into the image. php artisan config:cache/route:cache need POS_CURRENCY/POS_BUSINESS_NAME/APP_KEY to boot the framework at all, and none of those exist at docker build time — only at container start, when real env is present. The prod Dockerfile's composer dump-autoload --no-scripts exists specifically to skip package discovery, which would otherwise try to boot the framework mid-build and fail on the same missing vars; discovery and config caching both happen once, at first boot, when the env is real.
  • FrankenPHP is Caddy, which collapses reverse proxy, TLS termination, and the PHP runtime into one container. compose.prod.yml's api service is the single public entrypoint: its Caddyfile terminates TLS for both domains (auto-provisioned, auto-renewed), routes /api/* to itself, and reverse-proxies everything else to web:3000 or back-office:3000 by host. One image is the edge; there is no separate nginx or load balancer in front of it.
  • API_ORIGIN keeps the no-CORS principle alive in every environment. Both Next.js apps' /api rewrite reads process.env.API_ORIGIN; native dev falls back to http://127.0.0.1:8000, dev compose sets http://api:8000, and prod needs nothing at all — Caddy already routes /api/* to the api service before the request reaches a Next server. The browser has seen exactly one origin from M0 through prod; only the value behind the rewrite ever changed.
  • The dev containers drop root the moment they've done the one thing that needs it. A fresh named volume (api_vendor, *_node_modules) is root-owned by Docker, and the image's own non-root user can't write into it on first boot. Each dev service starts as root only to chown that volume once (a single stat, skipped on restart), then exec sus to the matching non-root user for the rest of the container's life. The host bind-mounted tree itself is never touched by root — verified with find backend frontend -user root coming back empty after a full make clean && make dev cycle. docker compose exec is a separate hazard from this: it defaults to root regardless, so every Makefile target that touches a bind mount names --user pos/--user node explicitly; see CLAUDE.md.
  • The restore drill is a make target, not a wiki page. make restore-drill spins up a throwaway Postgres container, restores the newest backup into it, prints row counts, and tears down, so "the backup works" is provable on demand instead of assumed. Proven for real, not just plausible: dropdb --force against a database with an active held connection (not merely an idle one) genuinely terminates it and drops the database — the exact case a stale connection would otherwise block, and the one make restore itself relies on.
  • make e2e reseeds twice, on purpose. All three committed end-to-end scripts transact at the same location on the same calendar day; e2e-admin-day.sh's sales-report assertions are absolute counts, proven standalone in M6 against its own fresh seed. Running it after the other two scripts (which also transact there, same day) makes that one assertion false without anything actually being broken — so make e2e reseeds once before the retail/lunch scripts and again before admin-day, restoring the exact precondition admin-day was written against. Reordering instead (admin-day first) doesn't work either: admin-day flips a till to food mode and reissues its device token, which the lunch script depends on still being retail-mode. The target leaves the dev db dirty on purpose afterward — see its own make help line.
  • The prod Compose project name pos is a real collision hazard, not a cosmetic choice. Only compose.prod.yml names its project poscompose.dev.yml is its own pos-dev, a separate volume namespace with no collision risk. compose.prod.yml's pos claims the pos_pgdata volume outright; a host that ever ran the retired infra/docker-compose.yml (same default project name) attaches to that same volume — a real database, not a fresh one — unless it's torn down with -v first or the prod stack boots under an overridden COMPOSE_PROJECT_NAME. Documented in the compose files themselves, not just here.

Next: nothing scheduled. See the deferred table below — M7 added five ops-shaped rows to it (monitoring, load test, runbook, registry/CD, worker mode) plus three hardening items surfaced while proving the restore drill and make e2e.


UI rework — one language, two surfaces

Not a milestone with new capability — a whole-product reskin, after M7. Both frontends moved onto one design language: the root DESIGN.md, an IBM/Carbon-calibrated spec — flat squares (0px corners everywhere, pills only on status badges), hairlines and surface change instead of drop shadows, IBM Plex Sans with weight-300 display type, sentence case, IBM Blue as the only accent. The register kept its two-pane till shape and gained a hard 48px touch floor (primary flows at 56–64px); the back office became a plate-layout admin — fixed sidebar with a location switcher, a Today landing, content on white plates over a gray canvas.

What held it together:

  • The frozen contract. Every existing user-visible label, flow, route, and behavior stayed byte-identical through the rework — proven by the unchanged label assertions riding through every task's gates (462 backend / 92 register / 131 back-office tests) and all three e2e scripts green after the cutover, untouched. Exactly three named exceptions, each documented in the Manager Guide in the same task that made it true: the Today landing (a new screen, so new labels), the location switcher relocated to the sidebar (the per-screen location pickers died with it), and window.confirm → styled Dialog (same copy verbatim; tests rewritten to same-semantics Dialog assertions).
  • Component vocabulary, review-enforced. If two screens render the same visual pattern, it is a component — screens compose the library, never inline styling. All DESIGN.md values enter code in exactly one file, src/styles/carbon.css, and the shared set (carbon.css, src/lib/utils.ts, all of src/components/ui/*, StatusPill/EmptyState/ConfirmDialog) is byte-identical between the two apps, diff-verified at the close.
  • Ergonomics live in the composites, not the screens. Register composites carry the till's sizing in their own classes (ActionZone 64px, TileButton ≥96px, PrepChip/PillStrip/CartLine at the 48px floor); back-office composites (DataTable, StatCard, FieldRow, SectionHeader) carry the plate idiom. A screen that needs the pattern gets the ergonomics for free.

Activation-code enrollment

A post-UI-rework feature, not a milestone: raw device tokens never cross the API anymore. A terminal enrolls by typing a short, human-typeable, one-time activation code — the till itself trades the code for its long-lived device token server-side.

  • Schema: one migration, three nullable columns on registers (activation_code_lookup — unique, a keyed HMAC, never the plaintext — activation_code_expires_at, activation_code_redeemed_at). No new table.
  • Routes: POST /api/v1/registers/activate (public, throttled 5/min by IP) replaces POST /api/v1/registers/enroll outright — gone, not deprecated. POST /api/v1/admin/registers/{id}/activation-code replaces POST /api/v1/admin/registers/{id}/token — also gone; an admin can no longer see or hand out a raw device token at all, only the opaque code. GET /api/v1/admin/registers items gained activation: { state, code_expires_at } (enrolled / code_pending / code_expired / not_enrolled).
  • The lockout is deliberately total. Issuing (or reissuing) a code deletes every device token and every staff session bound to that register, in the same transaction that stores the new code's HMAC — there is no window where an old credential and a new one are both live. The till finds out the instant it next talks to the server (invalid_device_token on any request) and shows "Terminal disabled": "Your activation code has been disabled. Please contact an admin and request a new activation code.", with the activation-code entry form directly below it — the same screen component (ActivationScreen) as first-run, just in its disabled variant.
  • Codes are stored the same way PINs are. ActivationCodes (10 chars from a 30-character alphabet with no 0/O, 1/I/L, U — legible over a phone call or a sticky note, displayed XXXXX-XXXXX) is looked up by a keyed HMAC-SHA256, the same "useless without APP_KEY" shape as users.pin_lookup. Single use (redeemed_at), expires after pos.registers.activation_code_ttl_days (7, config not database — an engineer's deployment knob, not a runtime admin setting).
  • Back office: the register editor's old "Reissue token" panel became "Issue activation code" — same destructive-confirm pattern, but the response is a code shown exactly once in a copy-me panel, never a token. An Activation status pill (Enrolled / Code pending — expires date / Code expired / Not enrolled) updates immediately on issue, without waiting for a refetch.
  • scripts/e2e-admin-day.sh updated, not left behind. It used to reissue Till 1's raw device token mid-script; that endpoint is gone, so the script now issues an activation code, asserts the old device token 401s, redeems the code via POST /registers/activate, confirms the new token works, and checks both audit actions (admin.register.code_issue, register.activate) — proving the full lockout-then-recovery cycle instead of a plain swap. e2e-retail-day.sh and e2e-lunch-service.sh don't touch registers this way and needed no change.
  • Docs: docs/03-api.md, docs/02-data-model.md, and docs/manual/ (Getting Started, Operator Guide, Manager Guide) all rewritten for the new story — no surviving reference to the old enroll-with-a-pasted-token screen or a reissued raw token anywhere current.

Status: complete. 476 backend tests, 112 register-app tests, 133 back-office-app tests. All three e2e scripts green via make e2e.


Manila catalog seeders

The Downtown/London demo seed — invented SKUs, invented prices, no real geography — is gone. Seeding now builds a believable Manila business instead.

  • POS_SEED_CATALOGS (env, default restaurant) picks a comma-separated subset of three catalogs, each with its own location, all Asia/Manila, prices_include_tax=true, 12% VAT with fresh produce VAT-exempt:
    • grocery (GRC) — 200 real Philippine retail items sourced from Open Food Facts, real brands; sourced items carry their own real-world EAN-13 barcodes and the 11 curated items with no sourceable barcode carry a generated, checksum-valid EAN-13 instead.
    • restaurant (RST) — 30 dishes off a researched Filipino menu, with rice/size/spice/add-on modifiers exercising the same modifier machinery M5 built.
    • cafe (CAF) — 20 drinks and pastries.
    • POS_CURRENCY is now PHP throughout.
  • Data is committed JSON, not generated at seed time — backend/database/seeders/data/{grocery,restaurant,cafe}.json — so the catalogs are reviewable and diffable like any other change. tests/Unit/SeedDataTest.php pins their shape (counts, currency, barcode format, modifier structure) so a bad edit to the JSON fails a fast unit test instead of surfacing as a weird e2e failure three layers away.
  • e2e re-anchored on the new geography: e2e-retail-day.sh and e2e-admin-day.sh now run against GRC, e2e-lunch-service.sh against RST. make e2e seeds all three catalogs once, then reseeds grocery alone before e2e-admin-day.sh so its sales-report assertions hold against a known-fresh count (see the deferred-table entry on delta-based assertions above).
  • Re-anchoring the e2e caught a real bug, not just a fixture mismatch. Retail's old Downtown location was tax-exclusive; GRC is tax-inclusive, and pushing refunds through it for the first time exposed a latent M4 defect: RefundOrder computed its refund basis as line_total_cents + tax_cents unconditionally. That's right when line_total_cents is net and tax is added on top, but at a tax-inclusive location line_total_cents is already the VAT-inclusive gross and tax_cents is only the portion embedded in it — so every refund at a tax-inclusive location was over-paying the customer by the VAT a second time. Fixed by branching on the order's own prices_include_tax snapshot (no new query — the locked order already carries it), with regression tests added at both tax modes (commit 7a0c0e0).

Status: complete. 490 backend tests, 112 register-app tests, 133 back-office-app tests. All three e2e scripts green via make e2e.


User manual

A screenshot-rich manual for the people actually running a store, not the people building it — docs/user-manual/: four Markdown sources (overview and back-office chapters, register chapters covering selling/food-service/shifts/shell, an FAQ, a troubleshooting guide, a glossary), 31 staged Playwright screenshots of the Manila seed (activation through Z-report on the register side, login through audit on the back-office side), and a WeasyPrint build turning both into a single 49-page PDF. make manual builds the PDF (pinned deps into a local venv, no system Python pollution); make manual-shots drives Playwright against a running make dev + seeded stack to (re)capture the screenshots. .github/workflows/manual.yml rebuilds the PDF on every push to main touching docs/user-manual/** and commits it back, mirroring wiki.yml's shape — the paths filter excludes the PDF and rendered diagrams themselves so the bot's own commit doesn't retrigger the workflow. The pipeline itself was ported from a sibling project's proven implementation rather than built from scratch.

Capturing the screenshots against a real seeded stack surfaced a real bug, not just a fixture mismatch: both frontends hardcoded a display-only USD const, so a Manila store priced in PHP still showed dollar signs on every screen. The server has always known its own currency; the catalog response and the admin-login response now carry it too, and each frontend reads it at boot instead of closing over a stale constant (commit c761424). The manual's screenshots were recaptured afterward, in pesos.

Status: complete. docs/user-manual/user-manual.pdf, 49 pages, builds clean via make manual; CI rebuild wired via manual.yml.


RBAC v2 and Settings

A 2026-07-22 audit of every state-changing route confirmed the permission gates themselves were sound, but found the management of those permissions still baked into code, plus two live bugs: GET /admin/reports/stock mis-gated on report.sales.view instead of its own permission, and a UI-created location silently provisioned with no roles at all (CreateLocation never called RoleProvisioner::provisionForLocation). This work turns roles into admin-editable data, adds per-user direct permission grants, opens the back office to anyone holding an admin-tier permission instead of is_admin alone, and ships a Settings surface — while fixing all three findings along the way.

  • Roles are templates now, not a hardcoded pair. role_templates + role_template_permissions (new tables, 02-data-model.md) hold a name and a permission set; RoleProvisioner materializes each template into spatie's per-location roles rows and keeps them in sync on every create/edit/rename. Two system templates (cashier, supervisor) seed once from the same permission sets this repo always specified; an admin can add a shift-lead or a bookkeeper without a migration, or edit either system template's permissions without renaming it. GET/POST /admin/roles, PATCH /admin/roles/{id}, POST /admin/roles/{id}/delete — a POST, keeping the "no DELETE verb under /admin/*" rule literal even though a role template really is deleted, not archived (it has no is_active column, and an unassigned template has nothing left pointing at it). GET /admin/permissions backs both the role editor and the user editor's pickers.
  • Direct per-location permission grants. users.permissions[] ([{location_id, permission}], full-set-replace, mirroring roles[]) writes to spatie's own model_has_permissions table — present since M2, unused until now. PermissionAssignments mirrors RoleAssignments's direct-table-join shape for the same reason: spatie's permissions() relation has the identical team-scoping gotcha as roles(). Grants union with role permissions at can() time under the same team context EnsureStaffSession already sets — zero register-tier code changed.
  • Back-office access is permission-based. EnsureBackOffice (was EnsureAdmin) admits any active user holding at least one admin-tier permission anywhere — a role or a direct grant — via a new AdminAccess domain service (holdsAnywhere/allHeld/sectionsFor/locationIdsWhere, all direct joins, same team-scoping reasoning as above). A second check matters just as much: the presented token must carry the admin Sanctum ability, or a register staff-session token belonging to a supervisor who already holds report.sales.view would pass the permission check alone. AdminSessionResource gained sections[] (which admin-tier screens this session may open) and report_location_ids (which locations its report permissions actually cover) so the sidebar and the location switcher both render only what the API would actually allow.
  • requires_supervisor on discounts is enforced, not just stored. The column existed since M2 with nothing checking it. ApplyDiscount now loads the discount inside the lock and re-checks order.discount.apply when the flag is true — 403 discount_needs_supervisor — while the route itself only enforces the floor (order.line.add). A discount flipped to requires_supervisor: false is a real cashier-safe discount for the first time. Note the shipped register UI still shows the Discount panel only to a supervisor's PIN session, so a cashier-safe discount is reachable at the API today but not yet from the till screen itself.
  • Settings, database-backed with a config fallback: a new settings table (key/value jsonb/timestamps) holds business.name, business.address, business.tax_id — receipts and the boot-time required-config check both read through App\Domain\Settings\Settings, which resolves database-override-or-config per key. PATCH with an explicit null clears an override back to config, on purpose — there is no way to store an explicit null, because a stored null would pin the key to the database forever. Plus two nullable columns on locations, variance_approval_threshold_cents and low_stock_threshold: null means "use the deployed config default," resolved at read time by ApproveVariance, CloseShiftResource, and StockReport alike.
  • The three quick fixes landed alongside the feature work, not as a follow-up: the stock report now gates on its own report.stock.view; CreateLocation provisions every current role template at a new location (closing the audit's confirmed bug); and a small consistency sweep (Makefile's make e2e admin password, both .env.examples' and the seeder's default catalog, capture_screenshots.mjs's admin password constant) that had drifted out of sync with each other.

Status: complete. 527 backend tests, 113 register-app tests, 166 back-office-app tests. All three e2e scripts green via make e2ee2e-admin-day.sh's activation-code proof and e2e-lunch-service.sh's supervisor-approval flow are both unchanged and still pass, confirming templates materialize the identical cashier/supervisor permission sets the hardcoded roles used to.


End Of Day

A location-scoped business-day close for the back office, the layer above a shift: a manager reconciles the day's registers, records the bank deposit and a fixed operational checklist, and freezes an immutable, self-contained day record. Closing a day forbids exactly one thing — opening a new shift at that location on that date — and is reversible only by an admin. The register app is untouched; everything reuses M2–M6 machinery (ShiftTotals, the day-basis SalesReport, AdminAccess, the audit log, the sidebar location switcher).

  • One new table, business_days (02-data-model.md) — a reconciliation snapshot, not a ledger. Closed iff a row exists AND reopened_at is null; unique (location_id, business_date) is the entire "close a day once" invariant, and the paired reopened_at/reopened_by check mirrors shifts.variance_approved_*. Reopening never deletes the row — a later close re-snapshots it and clears the pair, so the audit log carries the full close→reopen→close history off one row.
  • Three actions, app/Actions/Admin/Day/: CloseBusinessDay (asserts every shift closed and zero open orders for the date, snapshots gross/refunds/net/tax/cash/ variance/shift-count from the ledgers, upserts the row, audits day.close) — ReopenBusinessDay (is_admin only, mandatory reason, audits day.reopen, the only thing that un-forbids opening a shift on a closed date) — GetBusinessDay (read-only status: live totals, blockers, a non-blocking unapproved-variance warning, closable, the close record).
  • One write-path guard. OpenShift now refuses to open on a closed, un-reopened business day at the register's location — 409 day_closed. Nothing else is forbidden: variance approval, refunds, and reports stay legal on a closed day, same philosophy as variance itself not blocking a close.
  • One new permission, day.close (05-rbac.md) — admin-tier, granted by no default role (like location.manage/register.enroll; admins bypass), doubling as the back-office nav section (AdminAccess::SECTIONS). It gates read + close; reopen is is_admin only.
  • Back-office only, one new End of Day section: blockers panel, the consolidated Z, a checklist form (fixed items + a deposit-cents money input + a note), a Close action disabled while any blocker is present, and a read-only record with a Reopen button once a date is closed. Deposit is a recorded number and note, not a tracked safe ledger — deferred until a store actually draws floats from one.

Status: complete. Suites: 555 backend / 113 register / 177 back-office. The register app is untouched — its 113 are unchanged, which is the point. scripts/e2e-admin-day.sh gained steps 35–40: close the day, prove OpenShift refuses with 409 day_closed (after re-logging the cashier in, since closing her shift revoked the session), reopen as admin, and prove the guard lifts.


Payment methods complete

Cash and card were a hardcoded driver string on every payment and refund since M4 — fine for two tenders, wrong once a location wants to tell GCash apart from Maya at the drawer, or rename "Cards" without touching a code every report and receipt depends on. This work replaces the string with a per-location taxonomy: payment_method_groups (one row per driver-backed bucket) and payment_methods (the admin-named variants a till actually offers), full story in 02-data-model.md.

  • The one decision worth remembering: the group carries the driver, not the method. PaymentMethodResolver still turns a code into a driver exactly as before — nothing in PaymentDriver, DriverRegistry, or Capabilities changed, and no code seam moved. Methods are just admin data sitting above that seam. That's what makes CARD and EWALLET both drive external_card while staying separate groups (the Manila seed ships Visa/Mastercard under CARD and GCash/Maya under EWALLET) — a second e-wallet is a row now, not a class.
  • A code, a method's group, and a group's driver are all immutable after create. Moving a method between groups or repointing a group's driver would silently change live behaviour and retroactively re-bucket every payment already taken on it; PATCH drops those keys rather than erroring, the same shape every admin PATCH in this API has for a field it doesn't recognize. The fix for a wrong one is archive-and-recreate.
  • The bug class this closes: CreateLocation provisioned roles but not payment methods, so a location made in the back office would 422 on its first tender — the same shape RBAC v2 closed for roles, one table over. PaymentMethodProvisioner is RoleProvisioner's counterpart, idempotent by code, and every location (seeded or admin-created) now gets a working CASH/CARD pair for free.
  • Payments and refunds snapshot payment_method_code/payment_method_name (the order-lines rule, applied to tenders) while driver stays a plain derived column — which is why ShiftTotals and the payments_change_balances check needed zero changes. The three columns landed across two migrations on purpose: refunds stayed nullable until RefundOrder could write them, tightened only once its writer shipped.
  • The breaking wire change: POST /orders/{id}/payments and POST /refunds take payment_method_code instead of driver; the Z-report's sales_by_driver/ refunds_by_driver become sales_by_method/sales_by_group/refunds_by_method/ refunds_by_group, and GET /admin/reports/sales gains group_by=payment_method (ledger-basis, keyed on the snapshot columns). GET /catalog gains payment_methods[] so the register renders tender buttons from location data instead of two hardcoded ones. Full shapes in 03-api.md.
  • New permission payment_method.manage (05-rbac.md) — admin-tier, granted by no default role, doubling as its own back-office section, deliberately not moneyLeaves(): naming a tender moves no money, and taking one is still payment.take against a user and a shift. Six routes under /admin/payment-method-groups and /admin/payment-methods, no DELETE, location-scoped the same way the report permissions are.
  • All three e2e scripts updated: e2e-retail-day.sh and e2e-lunch-service.sh tender on method codes instead of driver strings; e2e-admin-day.sh exercises the new admin CRUD and asserts the Z-report's sales_by_method/sales_by_group for the sale it made (CASH, both = 510) — the script's own new VOUCHER group takes no payment, so that half of the rollup isn't asserted anywhere yet.

Status: complete. Suites: 614 backend / 123 register / 224 back-office.


On-screen keyboard

The register app assumes a physical keyboard everywhere: cash tendered, PINs, barcodes typed when a scan fails, table refs, card references, and — critically — the reasons attached to voids, discounts, and refunds are all free typing. A sealed all-in-one terminal or a tablet in a stand has nothing to type with, and the supervisor-gated, audited actions are exactly the ones that become unreachable. registers.screen_keyboard_enabled (boolean, default false, 02-data-model.md) puts a touch keyboard on a till that needs one, editable in the back office's RegisterEditor alongside Mode and Active.

  • The one decision worth remembering: one host, not sixteen keyboards. A single ScreenKeyboardHost mounts once at the register app root and listens for focusin on any input carrying data-screen-keyboard="numeric"|"full", docking one react-simple-keyboard at the bottom of the viewport when the flag is on. The alternative — threading onKeyPress through each of the sixteen opted-in input sites — is sixteen files touched, sixteen chances to miss one, and every future input silently opting out by default. The host costs exactly one sharp technique in exchange: writing into a controlled React input from outside requires the native value setter plus a dispatched input event, or React's own onChange never fires (React tracks the last value it saw itself; assigning el.value directly leaves that tracker stale). That technique is confined to one helper, setNativeValue, with the reason written down beside it.
  • react-simple-keyboard is the first third-party UI component in either frontend — a deliberate exception to DESIGN.md's hand-styled-on-Tailwind convention. A correct, accessible on-screen keyboard (shift state, key repeat, layout switching, touch targets) is a large amount of fiddly work with no product value in rewriting; the structural stylesheet ships as-is and only the theme is overridden, in a register-scoped stylesheet, using Carbon tokens.
  • The flag rides the same two resources mode already does: AdminRegisterResource, and the nested register object of EnrolledRegisterResource (the activation response) and StaffSessionResource (PIN login) — 03-api.md. The activation one matters most: the client persists that register object before any staff session exists, which is what lets the PIN screen itself show a keyboard.
  • Documented limitation, not an oversight: the activation and server-setup screens cannot show the keyboard, because at that point the client has no device token and no register to read a flag from. First setup of a keyboard-less terminal needs a keyboard attached once (or the code typed on a phone and pasted) — noted in the user manual rather than left as a discovery.
  • Per register, not per location, because a single store commonly mixes hardware — a sealed counter terminal beside a back-office PC enrolled as a second till. Defaults false because a terminal with a keyboard is the common case, and defaulting true would silently put a keyboard on every till already in service at migrate time.

Status: complete. Suites: 619 backend / 135 register / 225 back-office.


Pending variances

A read-only back-office queue: closed shifts whose drawer variance is over threshold and not yet signed off, so a supervisor can see which drawers need approval without logging into every register in turn to find out. Approval itself is unchanged and stays a register action.

  • One new admin endpoint, GET /admin/variances (app/Actions/Admin/Shifts/ListPendingVariances) — unpaginated, ordered by closed_at descending, gated shift.approve_variance, scoped server-side to every location the caller holds it at (is_admin: everywhere). No location_id parameter: the scope already is the caller's held locations, so there's nothing to filter.
  • One definition of "pending," reused rather than duplicated: the same three conditions ApproveVariance itself guards on — closed, abs(variance_cents) strictly over locations.variance_approval_threshold_cents (falling back to config('pos.shifts.variance_approval_threshold_cents'), resolved per row because the list spans locations), not yet approved. Strictly-greater matches ApproveVariance's own rejection (422 variance_approval_not_required) — an at-threshold row would offer an approval the API refuses.
  • shift.approve_variance joins AdminAccess::SECTIONS — the first register-tier permission to do so (05-rbac.md). No new permission and no role change: supervisors already hold it via Permissions::supervisor(), and they're exactly the audience for the queue.
  • The design decision worth remembering: the view deliberately does not link to the offending register. CloseShift revokes every staff session bound to it, so approving from the till showing the variance 401s; ApproveVariance scopes by location, so any other terminal there works. A queue that linked to the register would relocate the confusion, not remove it — instead a standing guidance line above the table says where to go. Approval stays register-side, not ported to the back office, because the audit trail is register-attributed: ApproveVariance audits with a registerId, and an admin session has none to give it.
  • Back office only, one new Variances section with a sidebar count badge (reusing the low-stock badge's pattern) and an EmptyState when nothing is pending. The register app is untouched.

Status: complete. Suites: 628 backend / 138 register / 233 back-office.


Sequencing rationale

  • Money before schema — everything computes on it.
  • Vertical slice before breadth — one thin sale proves the architecture while changing it is still cheap. Building all of retail before the first end-to-end sale means discovering a foundational mistake with five milestones stacked on it.
  • Retail before food service — retail is the shorter path through the same lifecycle, so it validates the shared core with less surface area.
  • Back office last — it has no customer waiting at a counter. psql and seeders cover the gap until then.

Deferred, with the trigger that revives each

Not "maybe someday" — each has a specific condition that should promote it.

Deferred Revive when
Real printer drivers (network / USB / serial) A printer physically exists. The shell, the Printer trait, and the ESC/POS encoder shipped; a driver is the small remaining part.
Offline-tolerant writes The first outage costs a real shift's revenue. The idempotency table is already the replay mechanism, and the desktop shell would be its host.
Stripe Terminal Someone wants card money to flow through our reports instead of a separate reader.
Kitchen display A kitchen asks. order_lines.prep_state is already there.
Queue + Redis The first thing worth doing async — realistically, emailed receipts.
Multi-tenancy Selling this to a second business. Costly by then, so decide early, not when the contract's signed.
Loyalty / gift cards A concrete promotion needs it.
Monitoring / alerting / log shipping First real deployment day.
Load test at lunch-rush concurrency First pilot store scheduled.
Runbook (register won't connect, drawer won't reconcile, restore from backup) First operator who isn't us.
Registry + CD pipeline First remote host to deploy to.
FrankenPHP worker mode (Octane) Measured latency need — off by default; the image already supports it.
Delta-based e2e-admin-day.sh assertions The e2e scripts need to compose without make e2e's double-reseed — today its sales-report checks are absolute counts that only hold against its own fresh seed.
COMPOSE_VAR hardening against a typo'd COMPOSE= A destructive backup/restore/restore-drill target is run with a mistyped COMPOSE=prod and silently falls back to the dev stack instead of failing loudly.
make e2e's device-token extraction guard checks non-empty, not token-shaped The seeder's printed table format shifts in a way test -s still passes (e.g. a column reflow) but the extracted string isn't a real id|hash token — today's guard would wave through garbage instead of failing at extraction.

Risks

  • Offline. The known, accepted cost of v1 (00-overview.md). Mitigation is the idempotency groundwork, not denial.
  • Tax complexity. Inclusive/exclusive is handled. Multi-jurisdiction US sales tax is not, and would be a real project.
  • Penny allocation. Contained, and tested in M1 rather than discovered in M5.
  • The unified model breaking down. Retired: M5 shipped as one new column (registers.mode) and one new constraint (a paired check on shift columns that already existed), zero new order-model tables (see M5's Status block). The hedge — M5 late, M4 early — paid off by not paying off; the risk simply didn't materialize.

Clone this wiki locally