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
Authorizationheader (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_statedict) and persisted tolicense_state.json - If cloud unreachable: uses last known state + 24h grace period
3. License Enforcement (Local Backend)
LicenseCheckMiddlewareruns on every request- Reads
license_statedict (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/healthalways passes through
4. Sysadmin Panel → Cloud Backend
- All API calls go to
VITE_CLOUD_URL(e.g.http://localhost:8001) - JWT Bearer token in
Authorizationheader (stored assysadmin_token) - On 401: redirect to
/login
5. Printer Routing (Local Backend)
- Triggered automatically on
POST /api/orders/{id}/items printer_service.pygroups items byproduct.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, neverp.text() - Print failure is logged but does NOT fail the order save (orders always save)
- Items with no
printer_zone_idare silently skipped
6. Site Registration Flow
- Sysadmin opens Sysadmin Panel → Register New Site
- Form submitted →
POST /api/sites/on cloud backend - Cloud generates
site_id(UUID) andsecret_key(urlsafe random), stores hashed key - Response includes plaintext
secret_key— shown once only - Sysadmin copies
SITE_IDandSITE_KEYintolocal_backend/.env - Local backend restarts → next heartbeat authenticates successfully
- 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
POST /api/auth/loginwith{username, pin}- Backend verifies PIN with bcrypt, issues JWT (8h expiry)
- Token stored in
localStorage - All requests send
Authorization: Bearer <token> - On 401: token cleared, redirect to
/login - Manager Dashboard rehydrates user from
GET /api/auth/meon app load
Sysadmin (Cloud)
POST /api/auth/loginon cloud backend with{username, password}- JWT stored as
sysadmin_tokenin localStorage - All cloud API requests send the token
- 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)
- Product image upload only works on existing products — no upload at creation time.
- 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.
- Some Manager Dashboard rough edges remain (noted during Phase 3 smoke test) — not blockers.
- 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.
- Cloud sync on Docker networking:
CLOUD_URL=http://cloud_backend:8001works within Docker Compose (same network). For production VPS deployment, use the public HTTPS URL. - SQLite for cloud backend is fine for a small number of sites (< 50). For scale, switch
DATABASE_URLto 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_backendcontainer 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.