Skip to content

Architecture

Shape

  • Web-on-LAN: one server box in the shop runs the API + Postgres; tills are browsers on the local network. Remote stations (a mobile cart) reach the same server over Tailscale.
  • Device bridge: the only OS-coupled code. A small Node service on each till owns the ESC/POS receipt printer, the cash-drawer kick, and the USB-serial scale, exposed as a tiny localhost HTTP API the browser calls. Everything else is OS-agnostic (Windows today, Linux whenever).
  • Payments: Stripe Terminal smart readers. DECIDED (Tai, 2026-07-06): counter stations get the BBPOS WisePOS E + Ethernet dock. Criteria: wired (a counter terminal must not depend on WiFi — the dock provides ethernet + continuous power) and ORDERABLE: standard Stripe accounts can buy the WisePOS E directly; the Verifone P630 from the original shortlist is restricted to invited/enterprise integrations. Canada + Interac confirmed, and Stripe's recommended integration for this reader is server-driven — exactly ours. (Full comparison: second-brain → Roasterie-POS → payment-stripe-terminal-findings.md.) Readers are driven server-side via Stripe's API; No card data in this system, ever — only PaymentIntent/charge references, brand, last-4. Voids refund against the original charge.
  • Reporting is a read layer (views) over the transactional schema, added after the POS itself works. Never the other way around.

Build order

  1. Sale screen on mock data (current) — get the till flow right visually first.
  2. Tender screen (cash, split, gift card; Stripe stubbed) + receipt preview.
  3. apps/server: Postgres schema as migrations + API; the web app switches from mock catalog to the real one. Domain types graduate to packages/shared.
  4. Device bridge spike on real hardware (printer, drawer, scale) — the riskiest integration, de-risked early once a stack exists to host it.
  5. Stripe Terminal integration; then AR/wholesale, gift cards, drawer sessions, migration of 13 years of history from the legacy system.

Schema

The full target schema (8 domains: customers, catalog, sales, AR, gift cards, drawer/shifts, employees, settings) was designed domain-by-domain against a restored backup of the legacy system, with per-domain migration mappings. It lives in the project planning vault and becomes apps/server SQL migrations in step 3.

Key modeling decisions:

  • Money: integers (cents) in code, numeric(12,2) in Postgres.
  • Ledgers, not balances: AR, gift cards, loyalty, and stock are append-only ledgers; balances are always derived sums, never hand-edited numbers.
  • History-preserving: products and sales are never overwritten or recycled; prices are snapshotted on the sale line.
  • Modifiers are products flagged is_modifier that can only be rung attached to the parent product currently being entered (sale_line_modifier) — still individually priced and reportable, never a standalone line.
  • Constraint-driven behavior lives on product + department attributes (quantity_capture: unit/weigh/keyed; requires_customer), not UI modes. Departments carry defaults that pre-fill the new-item wizard; the product's own attributes are authoritative at runtime.
  • Stock is a per-item opt-in capability (track_stock + a movement ledger), not a burden.
  • Tax is per-product via tax_class → effective-dated tax_rate rows (multi-rate ready).
  • Voids, not returns: a void flips the sale's status, posts tender-aware reversals (card → refund on the original charge, gift card → restore that card, on-account → reverse the AR charge), and logs a loss-prevention exception. Nothing is deleted.