Files
simple-pos-system/PLANS AND STRATEGIES/PROJECT_REFERENCE.md

21 KiB

POS System — Master Project Reference

Read this file at the start of any new Claude Code session to get a complete picture of the system. For session-specific instructions and known issues, also read CLAUDE_CODE_INSTRUCTIONS.md.


What We're Building

A local-first restaurant Point of Sale system. Waiters use their phones to take orders at tables. Orders are tracked in real time, routed to thermal printers in the kitchen and bar, and managed by a supervisor via a web dashboard. The system runs entirely on the restaurant's local network — internet outages have zero impact on operations.

A lightweight cloud backend handles licensing only: it verifies that each restaurant installation has a valid license and can remotely lock a site if needed. It does not touch orders, menus, or users.

What it is NOT:

  • Not a payment processor (no card terminals, no fiscal integration)
  • Not an IRS/tax reporting system
  • Not a kitchen display system (KDS) — printing only
  • Not SaaS — each restaurant runs its own local instance

System Architecture

┌──────────────────────────────────────────────────┐
│                  YOUR VPS (Cloud)                │
│                                                  │
│  cloud_backend  (FastAPI, port 8001)             │
│  sysadmin_panel (React/Vite, port 5175)          │
│                                                  │
│  Responsibilities:                               │
│  - Site registration (generates site_id + key)  │
│  - License management (expiry dates)             │
│  - Remote lock/unlock per site                   │
│  - Receives periodic heartbeats from local sites │
└─────────────────┬────────────────────────────────┘
                  │ HTTPS — heartbeat every 6h
                  │ 24h grace period if unreachable
┌─────────────────▼────────────────────────────────┐
│          RESTAURANT LOCAL NETWORK                │
│                                                  │
│  backend  (FastAPI, port 8000)                   │
│  SQLite DB (pos.db)                              │
│  Runs on: Raspberry Pi 4 or any Linux box        │
│  Static LAN IP (e.g. 192.168.1.10)              │
│                                                  │
│  Responsibilities:                               │
│  - All business logic (orders, tables, users)    │
│  - Printer routing (ESC/POS over TCP)            │
│  - Auth (JWT, username+PIN)                      │
│  - License enforcement (from cloud heartbeat)    │
└───────────┬──────────────────┬───────────────────┘
            │ LAN              │ LAN
┌───────────▼──────┐  ┌────────▼──────────────────┐
│  waiter_pwa      │  │  manager_dashboard         │
│  React PWA       │  │  React web app             │
│  Port 5173       │  │  Port 5174                 │
│  Waiters' phones │  │  Supervisor tablet/laptop  │
└───────────┬──────┘  └───────────────────────────┘
            │ LAN (TCP/9100)
┌───────────▼───────────────────────────────────────┐
│  Thermal Printers (Jolimark TP850UE)              │
│  Zone A: Kitchen printer  (e.g. 10.98.20.25:9100) │
│  Zone B: Bar printer      (e.g. 10.98.20.26:9100) │
└───────────────────────────────────────────────────┘

User Roles

Role Auth Frontend Can Do
Waiter Username + PIN Waiter PWA (phone) Open orders, add items, mark paid, close. Own tables only (or assistant tables).
Manager Username + PIN Manager Dashboard Everything waiters can + manage menu, waiters, tables, view reports.
Sysadmin (you) Username + Password Sysadmin Panel (cloud) Register sites, manage licenses, lock/unlock sites.

Tech Stack

Component Technology Notes
Local Backend FastAPI (Python 3.13) SQLAlchemy ORM, Pydantic v2, JWT auth
Local Database SQLite File: pos.db. Auto-migrated on startup.
Printer Protocol python-escpos ESC/POS over TCP. CP737 (n=29) for Greek.
Waiter PWA React 18 + Vite TailwindCSS, Zustand, Axios. Service worker for PWA.
Manager Dashboard React 18 + Vite TailwindCSS, React Query, Zustand, react-hot-toast.
Cloud Backend FastAPI (Python) SQLite (dev) / PostgreSQL (prod). JWT auth.
Sysadmin Panel React 18 + Vite TailwindCSS, Zustand, Axios. Dark theme, cyan accent.
Containerization Docker + Docker Compose All services in docker-compose.yml

Directory Structure

simple-pos-system/
├── docker-compose.yml          # All 5 services defined here
├── local_backend/
│   ├── main.py                 # FastAPI app, lifespan, middleware
│   ├── config.py               # Settings from .env (SITE_ID, SITE_KEY, CLOUD_URL, etc.)
│   ├── database.py             # SQLAlchemy engine + SessionLocal
│   ├── models/                 # SQLAlchemy ORM models
│   │   ├── user.py             # User, AssistantAssignment
│   │   ├── table.py            # Table, TableGroup
│   │   ├── product.py          # Product, Category, Option, Ingredient, PreferenceSet
│   │   ├── order.py            # Order, OrderItem, OrderWaiter, PrintLog
│   │   └── printer.py         # Printer
│   ├── schemas/                # Pydantic request/response schemas
│   ├── routers/
│   │   ├── auth.py             # POST /api/auth/login, /refresh, /me
│   │   ├── tables.py           # CRUD tables + table groups
│   │   ├── products.py         # CRUD products + categories + image upload
│   │   ├── orders.py           # Order lifecycle + item management
│   │   ├── waiters.py          # Waiter management
│   │   ├── reports.py          # Shift reports, order history
│   │   └── system.py          # Health, printer test, lock/unlock
│   ├── services/
│   │   ├── printer_service.py  # ESC/POS print routing logic
│   │   └── cloud_sync.py       # Background heartbeat task
│   ├── middleware/
│   │   └── license_check.py    # Blocks all requests if locked/expired
│   ├── seed.py                 # Dev data seeder
│   ├── pos.db                  # SQLite DB (not in git)
│   └── license_state.json      # Persisted license state (not in git)
│
├── cloud_backend/
│   ├── main.py                 # FastAPI app, seeds default admin on startup
│   ├── config.py               # Settings from .env (SECRET_KEY, ADMIN_USERNAME, etc.)
│   ├── database.py
│   ├── auth_utils.py           # JWT creation + verification, password hashing
│   ├── models/
│   │   ├── admin.py            # Admin table (sysadmin accounts)
│   │   └── site.py             # Site table (registered restaurants)
│   ├── schemas/
│   │   ├── admin.py            # LoginRequest, TokenOut
│   │   └── site.py             # SiteCreate, SiteOut, SiteCreatedOut, LockRequest, HeartbeatRequest/Response
│   └── routers/
│       ├── auth.py             # POST /api/auth/login
│       ├── sites.py            # Full CRUD + lock/unlock for sites
│       └── heartbeat.py        # POST /api/heartbeat/ (called by local backends)
│
├── waiter_pwa/
│   └── src/
│       ├── api/client.js       # Axios, points to VITE_API_URL
│       ├── store/authStore.js  # Zustand: token + user
│       └── pages/
│           ├── LoginPage.jsx
│           ├── TablesPage.jsx
│           └── OrderPage.jsx
│
├── manager_dashboard/
│   └── src/
│       ├── api/client.js       # Axios, points to VITE_API_URL
│       ├── store/authStore.js
│       └── pages/
│           ├── LoginPage.jsx   # PIN pad (manager/sysadmin only)
│           ├── DashboardPage.jsx  # Live table grid, 30s polling
│           ├── OrderDetailPage.jsx
│           ├── ProductsPage.jsx
│           ├── WaitersPage.jsx
│           ├── TablesPage.jsx
│           ├── ReportsPage.jsx
│           └── SettingsPage.jsx
│
├── sysadmin_panel/
│   └── src/
│       ├── api/client.js       # Axios, points to VITE_CLOUD_URL
│       ├── store/authStore.js  # Token stored as 'sysadmin_token'
│       └── pages/
│           ├── LoginPage.jsx   # Username + password (NOT PIN)
│           ├── SitesPage.jsx   # Card grid, status colors, 30s polling
│           ├── SiteDetailPage.jsx  # Lock/unlock/extend/delete
│           └── RegisterSitePage.jsx  # One-time secret key display
│
└── PLANS AND STRATEGIES/
    ├── CLAUDE_CODE_INSTRUCTIONS.md  # Session start instructions + known issues log
    ├── PROJECT_REFERENCE.md         # This file
    ├── TESTING_CHECKLIST.md         # Step-by-step end-to-end testing guide
    ├── 00_PROJECT_OVERVIEW.md
    ├── 01_LOCAL_BACKEND.md
    ├── 02_WAITER_PWA.md
    ├── 03_MANAGER_DASHBOARD.md
    ├── 04_CLOUD_BACKEND.md
    └── 05_SYSADMIN_PANEL.md

Docker Compose Services

All services defined in docker-compose.yml at project root.

Service Image Port Depends On Env File
cloud_backend Custom (Dockerfile) 8001 cloud_backend/.env
backend Custom (Dockerfile) 8000 local_backend/.env
waiter_pwa node:20-alpine 5173 backend waiter_pwa/.env
manager_dashboard node:20-alpine 5174 backend manager_dashboard/.env
sysadmin_panel node:20-alpine 5175 cloud_backend sysadmin_panel/.env

Volumes:

  • ./data/cloud:/app/data — cloud DB
  • ./local_backend/pos.db:/app/pos.db — local DB
  • ./local_backend/license_state.json:/app/license_state.json — persisted license state
  • ./data/product_images:/app/data/product_images — uploaded product photos
  • ./logo.png:/app/logo.png:ro — restaurant logo for receipts

Key Wiring: How Everything Connects

1. Waiter/Manager → Local Backend

  • All REST API calls go to VITE_API_URL (e.g. http://192.168.1.10:8000)
  • JWT Bearer token in Authorization header (stored in localStorage)
  • On 401 response: token cleared, redirect to /login

2. Local Backend → Cloud Backend (Heartbeat)

  • Background task in cloud_sync.py, fires on startup then every 6 hours
  • POST {CLOUD_URL}/api/heartbeat/
  • Headers: X-Site-ID: <site_id>, X-Site-Key: <secret_key>
  • Body: { version, uptime_seconds }
  • Response: { licensed, locked, lock_reason, expires_at }
  • Result stored in memory (license_state dict) and persisted to license_state.json
  • If cloud unreachable: uses last known state + 24h grace period

3. License Enforcement (Local Backend)

  • LicenseCheckMiddleware runs on every request
  • Reads license_state dict (in memory, updated by cloud sync)
  • If locked = True → HTTP 423 on all endpoints
  • If licensed = False → HTTP 402 on all endpoints
  • Exempt: GET /api/system/health always passes through

4. Sysadmin Panel → Cloud Backend

  • All API calls go to VITE_CLOUD_URL (e.g. http://localhost:8001)
  • JWT Bearer token in Authorization header (stored as sysadmin_token)
  • On 401: redirect to /login

5. Printer Routing (Local Backend)

  • Triggered automatically on POST /api/orders/{id}/items
  • printer_service.py groups items by product.printer_zone_id
  • For each printer zone: formats ESC/POS receipt, connects via TCP to printer.ip_address:printer.port
  • Greek text encoded as CP737 (n=29) bytes — raw p._raw() only, never p.text()
  • Print failure is logged but does NOT fail the order save (orders always save)
  • Items with no printer_zone_id are silently skipped

6. Site Registration Flow

  1. Sysadmin opens Sysadmin Panel → Register New Site
  2. Form submitted → POST /api/sites/ on cloud backend
  3. Cloud generates site_id (UUID) and secret_key (urlsafe random), stores hashed key
  4. Response includes plaintext secret_keyshown once only
  5. Sysadmin copies SITE_ID and SITE_KEY into local_backend/.env
  6. Local backend restarts → next heartbeat authenticates successfully
  7. Sysadmin Panel shows site as "Active" once heartbeat is received

Database Schema Summary

Local Backend (SQLite: pos.db)

Table Key Fields
users id, username, pin_hash, role (waiter/manager/sysadmin), is_active
tables id, number, label, is_active, group_id
table_groups id, name
categories id, name, color, sort_order
products id, name, category_id, base_price, is_available, printer_zone_id, image_url
product_options id, product_id, name, extra_cost
product_ingredients id, product_id, name, extra_cost
product_preference_sets id, product_id, name (exclusive-choice groups)
product_preference_choices id, preference_set_id, name, price_delta
printers id, name, ip_address, port, is_active
orders id, table_id, opened_by, status, opened_at, closed_at, closed_by
order_waiters order_id, waiter_id (many-to-many)
order_items id, order_id, product_id, quantity, unit_price (snapshot!), selected_options (JSON), removed_ingredients (JSON), status, printed
print_log id, order_id, printer_id, printed_at, item_ids (JSON), success, error_message

Cloud Backend (SQLite: cloud.db in dev, PostgreSQL in prod)

Table Key Fields
admins id, username, password_hash, role
sites id, site_id (UUID), name, owner_name, contact_email, secret_key_hash, is_active, is_locked, lock_reason, license_expires_at, last_seen_at, last_seen_ip

API Surface Summary

Local Backend (http://<ip>:8000)

Method Path Auth Notes
POST /api/auth/login None Body: {username, pin} → JWT
GET /api/auth/me JWT Returns current user
GET /api/tables/ JWT All tables + active order status
POST /api/tables/ Manager Create table
POST /api/tables/batch Manager Bulk create with prefix+count
GET/POST/PUT/DELETE /api/tables/groups Manager Table group management
GET /api/products/ JWT ?all=true includes unavailable (manager)
POST /api/products/{id}/image Manager Upload product image
GET/POST/PUT/DELETE /api/products/categories Manager Category management
GET /api/orders/ Manager All orders, filterable
GET /api/orders/my Waiter Own active orders
POST /api/orders/ Waiter Open new order {table_id}
POST /api/orders/{id}/items Waiter Add items, triggers printing
POST /api/orders/{id}/pay Waiter Mark items paid {item_ids}
POST /api/orders/{id}/close Waiter/Manager Close order
DELETE /api/orders/{id}/items/{item_id} Manager Cancel item
GET /api/waiters/ Manager All waiters
POST/PUT/DELETE /api/waiters/ Manager Waiter management
GET /api/reports/shift Manager Shift summary
GET /api/system/health None Liveness (exempt from license check)

Cloud Backend (http://<vps>:8001)

Method Path Auth Notes
POST /api/auth/login None Body: {username, password} → JWT
GET /api/sites/ Sysadmin JWT List all sites
POST /api/sites/ Sysadmin JWT Register site → returns secret_key once
GET /api/sites/{site_id} Sysadmin JWT Site detail
PUT /api/sites/{site_id} Sysadmin JWT Update info / extend license
POST /api/sites/{site_id}/lock Sysadmin JWT Lock with reason
POST /api/sites/{site_id}/unlock Sysadmin JWT Unlock
DELETE /api/sites/{site_id} Sysadmin JWT Deregister
POST /api/heartbeat/ Site headers Called by local backends
GET /health None Liveness

Environment Variables

local_backend/.env

SITE_ID=<UUID from site registration>
SITE_KEY=<secret key shown once at registration>
CLOUD_URL=http://cloud_backend:8001     # Use service name in Docker, or VPS URL in prod
SECRET_KEY=<long random string for JWT>
LICENSE_GRACE_HOURS=24

cloud_backend/.env

SECRET_KEY=<different long random string>
DATABASE_URL=sqlite:////app/data/cloud.db    # Or postgresql://... in prod
ACCESS_TOKEN_EXPIRE_MINUTES=60
ADMIN_USERNAME=sysadmin
ADMIN_PASSWORD=<your password>

waiter_pwa/.env

VITE_API_URL=http://<local-backend-ip>:8000

manager_dashboard/.env

VITE_API_URL=http://<local-backend-ip>:8000

sysadmin_panel/.env

VITE_CLOUD_URL=http://localhost:8001    # Or https://your-vps.com in prod

Printer Configuration

Hardware: Jolimark TP850UE, USB + Ethernet, static IP via NetFinder software. Protocol: ESC/POS over raw TCP, port 9100. Paper: 80mm = 48 characters wide at standard font.

Critical Greek text rules:

  • Code page: n=29 (CP737). Set immediately after connecting: p._raw(b'\x1b\x74\x1d')
  • ALL text sent as raw CP737 bytes: text.encode('cp737', errors='replace')
  • Never use p.text() for Greek — it does not handle CP737 correctly
  • p._raw(b'\x1b\x40') to reset printer before each job

Printers are configured in the local DB (printers table) via Manager Dashboard → Settings. Each product gets assigned a printer_zone_id via Manager Dashboard → Products. Items with no zone assigned are silently skipped at print time.


Auth Flow Details

Waiter / Manager

  1. POST /api/auth/login with {username, pin}
  2. Backend verifies PIN with bcrypt, issues JWT (8h expiry)
  3. Token stored in localStorage
  4. All requests send Authorization: Bearer <token>
  5. On 401: token cleared, redirect to /login
  6. Manager Dashboard rehydrates user from GET /api/auth/me on app load

Sysadmin (Cloud)

  1. POST /api/auth/login on cloud backend with {username, password}
  2. JWT stored as sysadmin_token in localStorage
  3. All cloud API requests send the token
  4. On 401: redirect to /login

Order Lifecycle

Table available
    ↓
POST /api/orders/ → status: "open"
    ↓
POST /api/orders/{id}/items → items added, printer triggered
    ↓
POST /api/orders/{id}/pay (partial) → status: "partially_paid"
    ↓
POST /api/orders/{id}/pay (all items) → status: "paid"
    ↓
POST /api/orders/{id}/close → status: "closed", table becomes available

Manager can also DELETE the order (status: "cancelled") or cancel individual items.


Known Issues & Limitations (as of Phase 5 completion)

  1. Product image upload only works on existing products — no upload at creation time.
  2. First heartbeat happens on local backend startup, not on a fixed schedule. If the cloud is down at startup, the first successful heartbeat may be up to 6h later.
  3. Some Manager Dashboard rough edges remain (noted during Phase 3 smoke test) — not blockers.
  4. SITE_KEY must be copied immediately at registration — the cloud never stores or shows the plaintext key again. If lost, deregister and re-register the site.
  5. Cloud sync on Docker networking: CLOUD_URL=http://cloud_backend:8001 works within Docker Compose (same network). For production VPS deployment, use the public HTTPS URL.
  6. SQLite for cloud backend is fine for a small number of sites (< 50). For scale, switch DATABASE_URL to PostgreSQL.

Production Deployment Notes

  • Local backend: Docker Compose on a Raspberry Pi 4 or any Linux box. Static LAN IP via DHCP reservation at the router.
  • Cloud backend: VPS with nginx reverse proxy + SSL (Let's Encrypt). Run cloud_backend container or as systemd service.
  • Sysadmin panel: Build with vite build, serve static files via nginx. Optionally protect with nginx basic auth.
  • Waiter PWA: Accessible from any phone on the LAN. For PWA install, serve over HTTPS (or use localhost on dev).
  • Printers: Static IPs assigned via NetFinder (Jolimark utility). Docker host must be on the same VLAN as printers, or routing must be configured.