-
Notifications
You must be signed in to change notification settings - Fork 0
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.
-
infra/docker-compose.yml:postgres:18-alpine, volume, healthcheck. -
backend/: Laravel 13, Sanctum, Postgres connection,/api/v1/health. - The
04-backend-conventions.mdskeleton:app/Actions,app/Domain,app/Exceptions/Domainwith theDomainExceptionbase and its render hook, and the directory layout./api/v1/healthis 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 runningpest+tsc -b. -
CLAUDE.mddocumenting 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:18moved the recommended mount to/var/lib/postgresql(not.../data); mounting the old path makes the container restart-loop on first boot. - Laravel's
phpunit.xmlships 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
DomainExceptionleaves Laravel's default shape leaking through, which breaks the one-code-path promise in03-api.md.
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.
-
Moneyvalue 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).
-
Centsbranded 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.
-
Tenderseparates applied from tendered, which is whyinsufficient_tender(422) now exists in03-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.
- 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-permissionper05-rbac.md: publish and edit the migrations for uuid team/morph keys, enable teams onlocation_id, seed the permission catalog and roles, set team context inEnsureStaffSession. -
config/pos.phpper04-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.mdclaimed 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, soNOT NULL). Admin is nowusers.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 withAPP_KEY) makes it one indexed query;pin_hashstays the authority. -
Sanctum had the same uuid problem as spatie (
morphs→uuidMorphs), which no document predicted. -
StaffLoginmust 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 failure05-rbac.mdwarns about. -
Anything reading role assignments must query
model_has_rolesdirectly, never theroles()relation — that relation scopes to the current team, so it silently answers a different question. Bit bothStaffDirectoryandUser::locationIds(). -
A constraint violation aborts the Postgres transaction, and
RefreshDatabasewraps each test in one. A test can provoke one violation, and nothing after it.
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 missingIdempotency-Keyheader; 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(). AnOrdercreated without explicit version/totals carries PHP nulls in memory even though Postgres wrote 0s.OpenOrdersets all six explicitly; any later action creating rows that lean on column defaults must do the same or->refresh(). -
Postgres
jsonbdoes not preserve object key order. An idempotency replay is content-identical but not byte-identical to the original response. Tests compare replays withtoEqual, nevertoBe. - 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 intests/Feature/escapes Pest'suses()binding — subtle but deliberate; seeConcurrentSaleTest. - 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 close —
CloseShiftrevokes 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.
- 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_carddriver. - 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,
/apirewrites 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 --noEmitgates instead. -
DiscountResolverreview 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_cardproved the driver seam. A new driver plus one validation rule, and zero changes toTakePayment,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.mdnow states the definition to cover both instead of treatingstock.adjustas an exception.
- 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 owncheck) and one new constraint — a pairedcheckonshifts.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-Matchand bumps noversion. 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:SetLinePrepStateis 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.keyis a bare primary key with no path or order in it, so reusing one anywhere in the system for a genuinely different request is409 idempotency_key_reused, full stop.01-architecture.mdalready 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.shapproves 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
UpdateLineQtyrather 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.
- 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, thePOST /registers/enrolladmin-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
DELETEroute anywhere under/admin/*— a category, product, variant, modifier, discount, tax rate, location, or register is retired withPATCH { "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.
salesgrouped byday/useris ledger-basis — summed from capturedpaymentsandrefunds, money that actually moved. Grouped bycategoryit'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'sbasisfield 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.
RoleAssignmentshad to read and writemodel_has_rolesdirectly a second time — spatie'sroles()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
CHECKis evaluated after every statement, not once at commit.UpdateUserwrites roles, then a PIN, then the plain columns, in that order, inside one transaction, becauseusers_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.
AdminProductResourceoriginally carriedmodifier_groupsonly 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}/tokendeletes 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/loginhas no register and no device, so it has no team context for spatie's per-location roles to hang off — the same reasonadminisn'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.
- 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:cacheneedPOS_CURRENCY/POS_BUSINESS_NAME/APP_KEYto boot the framework at all, and none of those exist atdocker buildtime — only at container start, when real env is present. The prod Dockerfile'scomposer dump-autoload --no-scriptsexists 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'sapiservice 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 toweb:3000orback-office:3000by host. One image is the edge; there is no separate nginx or load balancer in front of it. -
API_ORIGINkeeps the no-CORS principle alive in every environment. Both Next.js apps'/apirewrite readsprocess.env.API_ORIGIN; native dev falls back tohttp://127.0.0.1:8000, dev compose setshttp://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 tochownthat volume once (a singlestat, skipped on restart), thenexec 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 withfind backend frontend -user rootcoming back empty after a fullmake clean && make devcycle.docker compose execis a separate hazard from this: it defaults to root regardless, so every Makefile target that touches a bind mount names--user pos/--user nodeexplicitly; see CLAUDE.md. -
The restore drill is a
maketarget, not a wiki page.make restore-drillspins 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 --forceagainst 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 onemake restoreitself relies on. -
make e2ereseeds 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 — somake e2ereseeds 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 ownmake helpline. -
The prod Compose project name
posis a real collision hazard, not a cosmetic choice. Onlycompose.prod.ymlnames its projectpos—compose.dev.ymlis its ownpos-dev, a separate volume namespace with no collision risk.compose.prod.yml'sposclaims thepos_pgdatavolume outright; a host that ever ran the retiredinfra/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-vfirst or the prod stack boots under an overriddenCOMPOSE_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.
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 ofsrc/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 (
ActionZone64px,TileButton≥96px,PrepChip/PillStrip/CartLineat 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.
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) replacesPOST /api/v1/registers/enrolloutright — gone, not deprecated.POST /api/v1/admin/registers/{id}/activation-codereplacesPOST /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/registersitems gainedactivation: { 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_tokenon 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 itsdisabledvariant. -
Codes are stored the same way PINs are.
ActivationCodes(10 chars from a 30-character alphabet with no0/O,1/I/L,U— legible over a phone call or a sticky note, displayedXXXXX-XXXXX) is looked up by a keyed HMAC-SHA256, the same "useless withoutAPP_KEY" shape asusers.pin_lookup. Single use (redeemed_at), expires afterpos.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.shupdated, 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 viaPOST /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.shande2e-lunch-service.shdon't touch registers this way and needed no change. -
Docs:
docs/03-api.md,docs/02-data-model.md, anddocs/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.
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, defaultrestaurant) picks a comma-separated subset of three catalogs, each with its own location, allAsia/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_CURRENCYis nowPHPthroughout.
-
grocery (
-
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.phppins 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.shande2e-admin-day.shnow run against GRC,e2e-lunch-service.shagainst RST.make e2eseeds all three catalogs once, then reseeds grocery alone beforee2e-admin-day.shso 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:
RefundOrdercomputed its refund basis asline_total_cents + tax_centsunconditionally. That's right whenline_total_centsis net and tax is added on top, but at a tax-inclusive locationline_total_centsis already the VAT-inclusive gross andtax_centsis 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 ownprices_include_taxsnapshot (no new query — the locked order already carries it), with regression tests added at both tax modes (commit7a0c0e0).
Status: complete. 490 backend tests, 112 register-app tests, 133 back-office-app
tests. All three e2e scripts green via make e2e.
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.
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;RoleProvisionermaterializes each template into spatie's per-locationrolesrows 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 ashift-leador abookkeeperwithout 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— aPOST, keeping the "noDELETEverb under/admin/*" rule literal even though a role template really is deleted, not archived (it has nois_activecolumn, and an unassigned template has nothing left pointing at it).GET /admin/permissionsbacks both the role editor and the user editor's pickers. -
Direct per-location permission grants.
users.permissions[]([{location_id, permission}], full-set-replace, mirroringroles[]) writes to spatie's ownmodel_has_permissionstable — present since M2, unused until now.PermissionAssignmentsmirrorsRoleAssignments's direct-table-join shape for the same reason: spatie'spermissions()relation has the identical team-scoping gotcha asroles(). Grants union with role permissions atcan()time under the same team contextEnsureStaffSessionalready sets — zero register-tier code changed. -
Back-office access is permission-based.
EnsureBackOffice(wasEnsureAdmin) admits any active user holding at least one admin-tier permission anywhere — a role or a direct grant — via a newAdminAccessdomain 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 theadminSanctum ability, or a register staff-session token belonging to a supervisor who already holdsreport.sales.viewwould pass the permission check alone.AdminSessionResourcegainedsections[](which admin-tier screens this session may open) andreport_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_supervisoron discounts is enforced, not just stored. The column existed since M2 with nothing checking it.ApplyDiscountnow loads the discount inside the lock and re-checksorder.discount.applywhen the flag is true —403 discount_needs_supervisor— while the route itself only enforces the floor (order.line.add). A discount flipped torequires_supervisor: falseis 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
settingstable (key/valuejsonb/timestamps) holdsbusiness.name,business.address,business.tax_id— receipts and the boot-time required-config check both read throughApp\Domain\Settings\Settings, which resolves database-override-or-config per key.PATCHwith an explicitnullclears 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 onlocations,variance_approval_threshold_centsandlow_stock_threshold:nullmeans "use the deployed config default," resolved at read time byApproveVariance,CloseShiftResource, andStockReportalike. -
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;CreateLocationprovisions every current role template at a new location (closing the audit's confirmed bug); and a small consistency sweep (Makefile'smake e2eadmin 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 e2e — e2e-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.
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 ANDreopened_at is null;unique (location_id, business_date)is the entire "close a day once" invariant, and the pairedreopened_at/reopened_bycheck mirrorsshifts.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, auditsday.close) —ReopenBusinessDay(is_adminonly, mandatory reason, auditsday.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.
OpenShiftnow 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 (likelocation.manage/register.enroll; admins bypass), doubling as the back-office nav section (AdminAccess::SECTIONS). It gates read + close; reopen isis_adminonly. - 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.
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.
PaymentMethodResolverstill turns a code into a driver exactly as before — nothing inPaymentDriver,DriverRegistry, orCapabilitieschanged, and no code seam moved. Methods are just admin data sitting above that seam. That's what makesCARDandEWALLETboth driveexternal_cardwhile staying separate groups (the Manila seed ships Visa/Mastercard underCARDand GCash/Maya underEWALLET) — 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;
PATCHdrops those keys rather than erroring, the same shape every adminPATCHin 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:
CreateLocationprovisioned 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.PaymentMethodProvisionerisRoleProvisioner's counterpart, idempotent by code, and every location (seeded or admin-created) now gets a workingCASH/CARDpair for free. -
Payments and refunds snapshot
payment_method_code/payment_method_name(the order-lines rule, applied to tenders) whiledriverstays a plain derived column — which is whyShiftTotalsand thepayments_change_balancescheck needed zero changes. The three columns landed across two migrations on purpose:refundsstayed nullable untilRefundOrdercould write them, tightened only once its writer shipped. -
The breaking wire change:
POST /orders/{id}/paymentsandPOST /refundstakepayment_method_codeinstead ofdriver; the Z-report'ssales_by_driver/refunds_by_driverbecomesales_by_method/sales_by_group/refunds_by_method/refunds_by_group, andGET /admin/reports/salesgainsgroup_by=payment_method(ledger-basis, keyed on the snapshot columns).GET /cataloggainspayment_methods[]so the register renders tender buttons from location data instead of two hardcoded ones. Full shapes in03-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 notmoneyLeaves(): naming a tender moves no money, and taking one is stillpayment.takeagainst a user and a shift. Six routes under/admin/payment-method-groupsand/admin/payment-methods, noDELETE, location-scoped the same way the report permissions are. -
All three e2e scripts updated:
e2e-retail-day.shande2e-lunch-service.shtender on method codes instead ofdriverstrings;e2e-admin-day.shexercises the new admin CRUD and asserts the Z-report'ssales_by_method/sales_by_groupfor the sale it made (CASH, both= 510) — the script's own newVOUCHERgroup takes no payment, so that half of the rollup isn't asserted anywhere yet.
Status: complete. Suites: 614 backend / 123 register / 224 back-office.
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
ScreenKeyboardHostmounts once at the register app root and listens forfocusinon any input carryingdata-screen-keyboard="numeric"|"full", docking onereact-simple-keyboardat the bottom of the viewport when the flag is on. The alternative — threadingonKeyPressthrough 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 dispatchedinputevent, or React's ownonChangenever fires (React tracks the last value it saw itself; assigningel.valuedirectly leaves that tracker stale). That technique is confined to one helper,setNativeValue, with the reason written down beside it. -
react-simple-keyboardis the first third-party UI component in either frontend — a deliberate exception toDESIGN.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
modealready does:AdminRegisterResource, and the nestedregisterobject ofEnrolledRegisterResource(the activation response) andStaffSessionResource(PIN login) —03-api.md. The activation one matters most: the client persists thatregisterobject 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.
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 byclosed_atdescending, gatedshift.approve_variance, scoped server-side to every location the caller holds it at (is_admin: everywhere). Nolocation_idparameter: 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
ApproveVarianceitself guards on — closed,abs(variance_cents)strictly overlocations.variance_approval_threshold_cents(falling back toconfig('pos.shifts.variance_approval_threshold_cents'), resolved per row because the list spans locations), not yet approved. Strictly-greater matchesApproveVariance's own rejection (422 variance_approval_not_required) — an at-threshold row would offer an approval the API refuses. -
shift.approve_variancejoinsAdminAccess::SECTIONS— the first register-tier permission to do so (05-rbac.md). No new permission and no role change: supervisors already hold it viaPermissions::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.
CloseShiftrevokes every staff session bound to it, so approving from the till showing the variance 401s;ApproveVariancescopes 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:ApproveVarianceaudits with aregisterId, 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
EmptyStatewhen nothing is pending. The register app is untouched.
Status: complete. Suites: 628 backend / 138 register / 233 back-office.
- 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.
psqland seeders cover the gap until then.
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. |
-
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 pairedcheckon 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.
Synced from docs/ at 49febb9 — edit in the repo, not here.
User Manual
Technical Documentation