# 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: `, `X-Site-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_key` — **shown 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://: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://: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= SITE_KEY= CLOUD_URL=http://cloud_backend:8001 # Use service name in Docker, or VPS URL in prod SECRET_KEY= LICENSE_GRACE_HOURS=24 ``` ### `cloud_backend/.env` ``` SECRET_KEY= DATABASE_URL=sqlite:////app/data/cloud.db # Or postgresql://... in prod ACCESS_TOKEN_EXPIRE_MINUTES=60 ADMIN_USERNAME=sysadmin ADMIN_PASSWORD= ``` ### `waiter_pwa/.env` ``` VITE_API_URL=http://:8000 ``` ### `manager_dashboard/.env` ``` VITE_API_URL=http://: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 ` 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.