Design-first · Laravel 13 · PostgreSQL 18 · React 19 Wiki GitHub
POS

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

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:

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.

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:

M2 — Schema + auth

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:

M3 — The vertical slice

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

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:

M4 — Retail complete

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:

M5 — Food service complete

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:

M6 — Back office

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:

M7 — Production

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:

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:


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.

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.

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.

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).

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.

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.

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.

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


Sequencing rationale

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