- services/license.py: verifies the cloud's Ed25519-signed license token
(public key built in) and decides purely: valid → licensed until expiry,
then a 5-day grace, then blocked - never mid-service (deferred while a
workday is open, applied at close). Works offline for as long as the
license lasts: the "unlicensed after 72h without heartbeat" rule is gone.
- Tamper resistance: an edited token fails the signature ("unverified");
a clock earlier than the latest provable time (token issued_at, newest
order in the DB, stored high-water mark; 1 day tolerance) → "clock".
- apply_license() re-evaluates from the stored token at startup, after
every heartbeat attempt and when a workday closes. Cloud lock/unlock from
the token keeps the workday-deferred behaviour. Transition: a cloud
without tokens is trusted 72h per successful heartbeat.
- FIX: the promised 5-day grace after expiry never happened - the cloud's
licensed=false was applied immediately (402 on everything).
- FIX: license_state.json lived inside the container and was lost on every
re-creation; it now lives in the data volume (old path read once).
- /api/system/status: offline_days, license_verified, license_problem,
grace_over; lock_reason "clock"/"unverified"; grace days from the license
module (rounded up).
Tests: 18 unit checks (signature, tamper, other site/key, 364 days
offline, grace ±workday, inactive, clock rollback, transition) + 17 E2E
checks with a real cloud + site process (400 days offline, tampered file →
402, clock behind newest order, expiry deferred until workday close,
renewal, remote lock/unlock).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend's bridge network can't see the host's NICs, so HOST_IP from
install.sh went stale silently after a DHCP change. Now:
- services/netinfo_helper.py runs as a new `netinfo` service (same backend
image, network_mode: host): every 60s it picks the PHYSICAL LAN address
(main-table default-route NIC if real hardware, else first real NIC with
IPv4; never WireGuard/Tailscale/ZeroTier/bridges/veths, ignores 169.254)
and writes it to the shared `netinfo` volume. Stdlib only. On Docker
Desktop (linuxkit/WSL2 kernel) it reports "unsupported" instead of the
VM's meaningless address.
- services/lan_ip.py: one resolver used by /api/system/status (lan_ip +
lan_ip_info), the pairing QR and the cloud heartbeat's local_ip:
override (pos_settings network.lan_ip_override) → live detection (ignored
when older than 5 min) → HOST_IP. Flags `mismatch` when a pinned address
is no longer on any of the machine's NICs.
- PUT /api/system/lan-ip-override (manager): set a private IPv4 or null to
return to automatic; public/loopback/link-local/IPv6 rejected (422).
- cloud_sync._get_local_ip uses the resolver (no more socket trick that
returned the container IP).
Tested: helper selection on a fake sysfs/route table (8 cases incl. VPN
default routes) + real ioctl/route parsing on a Linux kernel; resolver
priority/staleness/mismatch/validation (18 cases); isolated full stack:
HOST_IP fallback on Docker Desktop, override save/validate/auth, simulated
Linux detection incl. DHCP change and dead helper, heartbeat IP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
local_backend now uploads each product's image file to cloud_backend
during the existing ~5 min menu sync, so the QR menu can show real
photos without needing a manually-set digital_image_url. Only
re-uploads images whose content hash changed since the last push
(Product.cloud_image_hash), to avoid re-sending unchanged binaries
every cycle.
Also adds a manual "sync now" trigger (POST /api/system/sync-menu) and
a matching button in Settings → Operation, for pushing menu/price/image
changes immediately instead of waiting for the next automatic cycle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts)
and background task for auto-expiring stale reservations
- Reports: print_products, print_categories, print_tables analytics endpoints
plus meta_products and business_day_summary for workday close/view flow
- printer_service: configurable font sizes/weights, donut/bar chart print layout helpers,
analytics print blocks per printer
- tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer
- demo_seed.py for quick dev DB population; wipe_database.py utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_connect_loop now waits up to 30 seconds for the heartbeat to return
site_numeric_id, then immediately pushes the menu snapshot and stats
before entering the regular poll loop. Previously the first push
wouldn't fire until 5 minutes after startup, making the menu
unavailable until then.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds _push_stats_snapshot() to cloud_sync.py. Every 5 minutes
(piggybacked on the existing _connect_loop push tick alongside the
menu snapshot) it queries the local DB and POSTs a JSON stats blob
to POST /api/remote/snapshot (site API key auth).
Stats collected:
- open_tables: count of open/partially_paid POS orders
- today_revenue: sum of active+paid item prices on orders
closed today
- today_orders: count of paid/closed POS orders today
- online_orders_pending: online orders awaiting acceptance
- online_orders_today: all online orders opened today
- current_shift: active waiter shift info (waiter_id, started_at)
if a business day is open; null otherwise
- as_of: UTC timestamp of the snapshot
The remote manager dashboard reads this via
GET /api/remote/sites/{id}/snapshot (manager JWT).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Phase 4 _mirror_status_to_cloud function had no way to look up the
cloud order's numeric id once it was marked synced (not in the pending
list anymore), so status updates from local staff could silently fail.
Fix:
- models/order.py: online_order_cloud_id INTEGER column added to Order
- main.py: migration for the new column
- schemas/order.py: online_order_cloud_id exposed in OrderOut
- cloud_sync.py: stores cloud_order["id"] as online_order_cloud_id
when creating the local order during the pull
- connect_orders.py: _mirror_status_to_cloud now takes the integer
cloud id directly — no more pending-list lookup; function body
reduced from ~50 lines to ~15
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.1 — cloud_sync.py
- _push_menu_snapshot(): serializes digital-visible products+categories
and POSTs to cloud /api/menu/sync every 5 minutes
- _pull_pending_orders(): polls cloud /api/orders/pending/{site_id}
every CONNECT_SYNC_INTERVAL_SECONDS (default 30s); creates local
Order + OrderItem rows, marks synced on cloud, broadcasts SSE event
- _connect_loop(): second asyncio task running the fast poll loop;
piggybacked menu push fires every 5 min regardless of poll interval
- _sync_once(): captures site_numeric_id from heartbeat response and
stores it in license_state so Connect loops can use it
- start_cloud_sync(): now creates and returns both tasks
4.2 — orders model/schema/migrations
- models/order.py: table_id made nullable (online orders have no
table); 7 new online_* columns added to Order
- schemas/order.py: OrderOut table_id Optional, all 7 online_* fields
added
- main.py: 8 additive ALTER TABLE migrations for orders table
4.3 — routers/connect_orders.py (NEW)
GET /api/connect/orders/incoming — pending online orders (any auth)
POST /api/connect/orders/{id}/accept — accept (manager)
POST /api/connect/orders/{id}/reject — reject with optional reason (manager)
POST /api/connect/orders/{id}/status — progress through lifecycle (manager)
All state changes mirror to cloud via background task and broadcast SSE
4.4 — main.py router registration
connect_orders router registered at /api/connect
config.py
CONNECT_SYNC_INTERVAL_SECONDS setting added (default 30)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Includes all work to date:
- local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync
- manager_dashboard: React manager UI with product/category management, reports, settings
- waiter_pwa: React PWA for waiter devices
- Category reparent endpoint and UI
- Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response
- QR code modal in AppInfoTab for waiter domain
- Product form: number input spinners removed, category pre-selected on new product
- Category row: count badge moved to far right
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>