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

Architecture

Stack

Versions verified against the registries on 2026-07-15, not from memory.

Layer Choice Version
Language (backend) PHP 8.5.0
Backend framework Laravel 13.20
API auth Laravel Sanctum 4.3
Database PostgreSQL 18 (postgres:18-alpine)
Frontend React + TypeScript 19.2 / 7.0
Frontend framework Next.js (app router) + TanStack React Query 16
Local infra Docker Compose 29.1

Notes on the less obvious picks:

Topology

┌──────────────┐   HTTPS/JSON    ┌──────────────────┐
│ Register     │ ──────────────► │ Laravel API      │
│ React SPA    │ ◄────────────── │ (stateless)      │
│ (browser)    │                 └────────┬─────────┘
└──────────────┘                          │
                                          ▼
                                  ┌──────────────┐
                                  │ PostgreSQL   │
                                  └──────────────┘

The API is stateless; all session state is in tokens and the database. That keeps the door open to running several API instances behind a load balancer without touching application code.

There is no queue worker in v1. Everything a sale needs happens synchronously inside the request. Receipt emails and report generation are the first things that will want a queue; when that day comes, add Redis and Horizon. Adding it now would be scaffolding with nothing to run on it.

frontend/web/ is a Next.js app serving one client-boundary register under a server shell; /api rewrites replace the Vite dev proxy (same single-origin story).

M6 added a second frontend, not a route inside the first. frontend/back-office/ is its own Next.js app on its own port (5175), talking to the same API through the same /api rewrite pattern. The two apps share conventions (money formatting, catalog types, the shape of the API client) but not a build or a deployment — a cashier's terminal never ships back-office code, and a back-office session never needs the register's offline or hardware seams. The original plan called this "routes within one app, separated by permission"; building it showed that a device-token-authenticated register and a password-authenticated, location-less back office are different enough sessions that sharing a build bought nothing and cost a permission check on every route to keep the two audiences apart.

frontend/native/ is reserved for a desktop shell (Electron or Tauri) and now exists. It is not a second frontend: the plan is that it hosts the same SPA and adds the two things a browser cannot do.

1. Hardware. This is the real reason it exists. A browser tab cannot kick a cash drawer, drive an ESC/POS thermal printer, or talk to a scale. WebUSB and WebSerial exist but are permission-prompted, Chromium-only, and not something to bet a lunch rush on. Meanwhile 02-data-model.md has a cash drawer, 05-rbac.md gates drawer.no_sale, and 03-api.md returns a receipt — none of which physically happen without a hardware bridge. So the design keeps a seam:

Built as of the shell milestone: the SPA is bundled as a static export, API traffic detours through a Rust api_request command (no CORS, and the server address lives in Rust so the webview never names a host), and receipt JSON becomes ESC/POS bytes in a pure Rust function. POST /api/v1/drawer/no-sale finally gives drawer.no_sale a door: the server authorizes and audits, the shell only pulses. Only the mock driver ships — it writes the exact bytes to disk — because no printer has been bought yet.

The rule is that no money decision ever lives in the shell. A shell that decided when a drawer may open would put the fraud boundary on the terminal, where it can't be audited.

Until the shell exists, receipts print via the browser's print dialog and the drawer is opened by hand. That's genuinely usable for a pilot and is why hardware isn't blocking v1.

2. Offline, eventually. A desktop shell can hold a local database, which makes the offline-tolerant path in 00-overview.md reachable rather than theoretical. That is not licence to start caching writes now — the v1 decision stands, and the idempotency keys are the on-ramp. It only means the road exists when the trigger fires.

Deployment topology

Three images, one edge, single-host docker compose for both dev and prod — no Kubernetes, no registry, no separate load balancer. docs/06-roadmap.md's M7 section has the full story of what shipped and what's a named deferral.

The backend follows a strict action-class architecture: one system action is one route, one single-action controller, one Action class, returned through one Resource. The rules, the layering, and a worked example are in 04-backend-conventions.md. Two decisions in this document — where the transaction boundary sits and where the optimistic-lock check happens — are resolved there, because both depend on that structure.

Money

The rule from 00-overview.md, made concrete, because this is where POS systems get quietly and expensively wrong.

Implemented in app/Domain/Money/ (Money, Quantity, TaxRate, Discount, Tender) and mirrored on the client in frontend/web/src/lib/money.ts.

Rounding

Rounding is only ever needed for percentage-based math: tax and percentage discounts. Every such calculation:

  1. Computes in integers.
  2. Rounds half away from zero at the point of the percentage application.
  3. Rounds per line, then sums. Never sums then rounds.

Per-line rounding is chosen because the receipt must add up in front of a customer who is checking it with their phone. Sum-then-round produces a total that is arithmetically defensible and visibly wrong on paper, and "the receipt is wrong" is a conversation no cashier should have to win.

The remaining hazard is penny allocation when splitting a bill: three ways on 1000 cents is 334, 333, 333 — the earliest part absorbs the remainder. The rule itself is arbitrary; that it is deterministic and totals exactly is not. Never split by dividing and rounding each share, which invents or destroys pennies.

Implemented as Money::allocate() / allocateByRatios(), with a property test asserting the parts always sum to the whole across every amount and split — written before the feature that needed it, not after.

Tax-inclusive vs tax-exclusive

A general-purpose POS cannot dodge this. US retail adds tax at checkout; EU/UK/AU display it in the shelf price.

locations.prices_include_tax (boolean) drives it:

Both paths store tax_amount on the line, so downstream reporting never needs to know which mode was used. The mode affects extraction, not representation.

Auth

Two distinct concepts that POS systems routinely conflate, to their cost:

  1. Register authentication. A terminal is enrolled once and holds a long-lived Sanctum token identifying the device. This is the machine's identity.
  2. Staff authorization. A cashier taps a 4–6 digit PIN to start acting on that register. This yields a short-lived staff session (default 8h, ends at shift close).

They are separate because the device is trusted (it's physically in your store, it was enrolled by an admin) but the person is not (they change every few hours). A PIN is a weak secret and is only acceptable because it's presented from an already-authenticated device on a private network — a PIN alone is never sufficient to reach the API.

PINs are hashed with bcrypt, are never logged, and are rate-limited per register (5 attempts, then a 60s lockout) to blunt the small keyspace. Back-office users log in with a real email and password; they do not get PINs.

  1. Back-office authentication. A third, independent tier, added in M6: POST /admin/login takes an email and password and returns a Sanctum token with no device and no location behind it at all — there is no terminal to trust and no register to read a team id from. Because of that, back-office access can't be scoped by team context the way the register is; instead it's permission-based: any active user holding at least one admin-tier permission at any location — via a role or a direct grant — gets in, and the token itself carries a distinct admin Sanctum ability so a register staff session can never pass as one. users.is_admin remains the one all-access flag, bypassing every check regardless of location. 05-rbac.md has the full mechanism: the admin-tier permission set, the two-check gate, and why reports stay location-scoped even though login itself isn't.

Roles

Roles are admin-editable templates, not a fixed set — a template (name plus permission set) is materialized into a per-location role at every location, and an admin can add, edit, rename, or delete one at runtime. Two are seeded as system templates and cover the common cases out of the box:

Their permission sets can be edited; their names can't (renaming or deleting either would strand seeds, scripts, and docs that assume both exist). A user can also hold a permission directly at one location without a template to carry it. admin is still not a role at all — users.is_admin plus a Gate::before bypass, because spatie's teams cannot express a role assignment that spans locations. Full rationale in 05-rbac.md.

The actions gated at supervisor's default permission set are exactly the ones that let someone take value out of the business without a customer noticing — cash out of the drawer or sellable stock out of the count. That's the whole design rationale: the permission boundary follows the fraud surface, not an org chart.

Implemented with spatie/laravel-permission, and role assignments are scoped per location — a supervisor at one store is not a supervisor at another. Call sites ask can('order.discount.apply'), never role === 'supervisor'. The permission catalog, role-template mechanics, direct per-location grants, and the split between permissions (capability) and policies (record access) are all in 05-rbac.md.

Idempotency

Non-negotiable even though we're online-only. The failure this prevents: a cashier taps "Charge $50", the response is lost to a flaky network, the client retries, and the customer is charged twice.

The routes carrying the idempotent middleware — add-line, payments, refunds, and shift close — accept an Idempotency-Key header (client-generated UUIDv4). Payments, refunds, and shift close require it.

idempotency_keys
  key           text primary key
  request_hash  text        -- SHA-256 of method + path + body
  response_code int
  response_body jsonb
  created_at    timestamptz

Handling:

  1. Key unseen → process, store the response, return it.
  2. Key seen, request_hash matches → return the stored response verbatim. Do not re-execute.
  3. Key seen, request_hash differs → 409 Conflict. The same key was reused for a different request, which means the client has a bug, and guessing which one they meant is worse than telling them.

Insertion of the key and the work it guards happen in one transaction, so a crash mid-write cannot leave a key claiming success for work that rolled back. That constraint forces the idempotency middleware to open the transaction that the action then nests inside; see 04-backend-conventions.md.

Keys are pruned after 24h — comfortably longer than any client will retry.

This table is also precisely the mechanism an offline write-queue would replay through, which is why it's in v1 despite v1 being online-only.

Concurrency

Two servers can touch one tab at once. Two cashiers can sell the last unit.

That last pattern — push the invariant into the database where it can't be raced — is the preferred solution throughout. Application-level checks are a fallback for invariants Postgres can't express.

Payment driver contract

Cash is the first driver, but the seam is defined now so a processor doesn't require reshaping the order flow later.

interface PaymentDriver
{
    public function code(): string;              // 'cash', 'external_card', 'stripe_terminal'
    public function capabilities(): Capabilities; // refundable? needs hardware? async?

    // Begin a tender. May complete immediately (cash) or return PENDING (terminal).
    public function authorize(PaymentIntent $intent): PaymentResult;

    // Settle a prior authorization. Cash is a no-op; card processors are not.
    public function capture(Payment $payment): PaymentResult;

    public function refund(Payment $payment, int $amountCents): PaymentResult;
    public function void(Payment $payment): PaymentResult;
}

The contract is authorize/capture even though cash doesn't need two steps, because a driver that can't express "pending, waiting on the customer to tap" forces every caller to be rewritten the day we add a real reader. Cash implements capture as a no-op returning success. That's a small, contained lie in the cash driver, versus a large refactor of the order flow later.

Drivers are resolved from a registry keyed by code, so adding one is a class plus a config entry.

v1 ships:

Groups and methods (02-data-model.md) sit above this seam, not inside it. A payment method group is per-location admin data naming exactly one driver; its methods are names an admin gives to behaviour the driver already implements. That split is what decides where new work goes: a second e-wallet or a named card scheme is a row, because it behaves identically to something already here; Stripe Terminal is still a driver class, because it behaves differently. Nothing in PaymentDriver, DriverRegistry or Capabilities knows that methods exist — PaymentMethodResolver turns a code into a driver and the rest of the payment path is untouched.

Error format

One shape, everywhere, so the client has one code path:

{
  "error": {
    "code": "insufficient_stock",
    "message": "Only 2 units of SKU-1234 remain.",
    "details": { "variant_id": "...", "requested": 5, "available": 2 }
  }
}

code is a stable machine-readable string — clients branch on it, and it never changes once shipped. message is human-readable, may change, and is never parsed. details is code-specific and typed per code on the frontend.

Testing