# 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_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://<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.
