Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ DOORDASH_BASE_URL=
# token. The link is built from NEXT_PUBLIC_APP_URL (below) or the request
# origin when unset.

# ---- Observability (Phase 7) ------------------------------------------------
# ALL OPTIONAL. The app emits structured JSON logs and threads a request/trace
# id through API routes with NONE of these set.
# • LOG_LEVEL: minimum log level (debug|info|warn|error). Defaults to `info`.
# • SENTRY_DSN: when set, the error-tracking seam (src/lib/observability/errors)
# routes captured errors to an external tracker; UNSET → structured-log no-op.
# The Sentry SDK is intentionally not yet a dependency (live wiring deferred).
LOG_LEVEL=
SENTRY_DSN=

# ---- Training / demo mode (Phase 7) -----------------------------------------
# OPTIONAL. TRAINING_MODE=1 (or running on the mock driver, the current default)
# marks the deployment as NOT taking real orders/money so staff can train. See
# docs/PRODUCTION_READINESS.md. Accepts 1|true|on|yes.
TRAINING_MODE=

# ---- App ---------------------------------------------------------------------
# Public base URL of this deployment (used for callbacks/webhooks + magic links).
NEXT_PUBLIC_APP_URL=
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,69 @@ jobs:
- name: Build
# Must build with NO env vars set (Supabase deferred).
run: npm run build

test:
name: vitest (unit + mock-driver, zero env)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Run test suite
# The whole suite runs against pure logic + the in-memory mock driver,
# so it must pass with NO env vars set (mirrors the build invariant).
run: npm run test:run

rls-isolation:
name: RLS isolation (Postgres) — optional, non-blocking
runs-on: ubuntu-latest
# This job spins up a throwaway Postgres, applies the tenancy migrations, and
# runs supabase/tests/rls_isolation.sql. It is OPTIONAL: a live DB is not a CI
# dependency for the platform, so a failure here must NOT block a PR. The
# required gates are `build` + `test` above.
continue-on-error: true
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pos_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
PGPASSWORD: postgres
DATABASE_URL: postgres://postgres:postgres@localhost:5432/pos_test
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Apply auth.uid() shim
# Vanilla Postgres lacks Supabase's `auth` schema / `auth.uid()`, which the
# tenancy RLS policies depend on. This shim recreates that exact contract so
# the migrations apply and the isolation test exercises real RLS off-platform.
# NOT applied on a real Supabase project (the platform supplies auth.uid()).
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/auth_shim.sql

- name: Apply tenancy migrations
run: |
for f in supabase/migrations/*.sql; do
echo "Applying $f"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"
done

- name: Run RLS isolation test
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f supabase/tests/rls_isolation.sql
65 changes: 65 additions & 0 deletions docs/IDEMPOTENCY_REVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Idempotency & Double-Charge Review (Phase 7)

A focused review of every place a retry, replay, double-tap, or lost response
could create a duplicate order, a double charge, or an incorrect settlement —
plus the automated tests that pin each guarantee. All money is integer minor
units (cents / USDC base units); there is no floating-point money path.

## The end-to-end idempotency key

A single **client-generated UUID** flows through the whole lifecycle:

- It is the **order** primary key (`orders.id`) and the offline-queue key.
- It is the **payment tender** primary key (`payments.id`) **and** the rail
`Idempotency-Key` forwarded to Stripe/Coinbase/onchain.

Because the same id is the DB key and the rail key, a retry maps to exactly one
row and one upstream charge.

## Order intake

| Path | Guarantee | Where | Test |
|---|---|---|---|
| `POST /api/orders` → `driver.createOrder` | Upsert-by-UUID; existing id returns the stored order unchanged, order number assigned once. | `src/lib/db/mock.ts` `createOrder` | `src/lib/offline/sync.test.ts` |
| Offline queue enqueue | Keyed by order UUID; re-enqueue of a synced/pending order does not duplicate. | `src/lib/offline/queue.ts` | `src/lib/offline/queue.test.ts` |
| Offline flush | Re-entrant-guarded; safe under reconnect + interval + manual triggers; POSTs are idempotent server-side. | `src/lib/offline/sync.ts` | covered via the upsert test |

## Payments

| Path | Guarantee | Where | Test |
|---|---|---|---|
| `takePayment` | Repeat `paymentId` returns the existing tender → **no second charge** (checked **before** calling the rail). | `src/lib/payments/service.ts` | `payments/service.test.ts` (idempotency, one tender) |
| Settlement | Order → `paid` only when **settled** (captured/authorized) tenders cover the total; never downgrades a paid order. | `maybeMarkOrderPaid` | `payments/service.test.ts` (settled-covers, split) |
| Split payment | Balance driven to zero across multiple tenders/rails; `getOrderBalance` never negative. | `getOrderBalance` / `coveredCents` | `payments/service.test.ts` (split) |
| Crypto pending | Pending crypto counts toward the displayed balance but **not** toward `paid`; confirm → settle. | `coveredCents` vs `settledCents` | `payments/service.test.ts` (crypto pending → confirm) |
| Refund | Tender `refunded` only when fully refunded; order `refunded` only when **all** tenders refunded; partial refund does neither. | `refundPayment` | `payments/service.test.ts` (refund full + partial) |
| Platform fee | Card-only `application_fee`; `pct(bps)+flat`, round-half-up, clamped to the charge. Integer cents. | `payments/fees.ts` | `payments/fees.test.ts` |

## Deliveries & end-of-day

| Path | Guarantee | Where | Test |
|---|---|---|---|
| `dispatchDelivery` | Idempotent on the order id (existing delivery returned). | `src/lib/delivery/service.ts` | exercised via service tests |
| `closeBusinessDay` | Idempotent: re-closing returns the frozen Z-report snapshot. | `src/lib/db/mock.ts` | `db/mock-drawer.test.ts` |

## Findings while writing the tests

- **No double-charge / duplicate-order bugs were found.** The end-to-end UUID
idempotency model held under every retry/replay/split scenario tested.
- **Documented behavioural nuance (not a bug):** `refreshPaymentStatus`
early-returns when a tender is *already* `captured`/`refunded` and therefore
does **not** re-run `maybeMarkOrderPaid`. This is correct for the intended flow
(the watcher/webhook transitions pending → captured *through*
`refreshPaymentStatus`, which settles the order in the same call). If a future
live webhook captures a tender by writing the status **directly** via
`upsertPayment`, it must also call `maybeMarkOrderPaid` (or route through
`refreshPaymentStatus`) so the order settles. The crypto-confirm test exercises
the correct watcher path (`payments/service.test.ts`). Noted here for the
live-wiring phase.

## Live-wiring follow-ups

- Forward the tender UUID as the Stripe `Idempotency-Key` (already wired in the
rails) and as the crypto deposit reference/memo.
- Make all webhook handlers idempotent on the rail charge id, and have any
direct-capture webhook settle the order (see the nuance above).
211 changes: 211 additions & 0 deletions docs/PRODUCTION_READINESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# Production Readiness — Pizzeria POS (SaaS)

Phase 7 hardening. This document is the go-live checklist + the standing record
of the platform's correctness/security posture. It covers what is proven **now**
(against the mock driver + pure logic, zero env vars) and what remains gated on
the **final live-wiring phase** (real Supabase + Stripe + crypto + DoorDash).

> Status of the build today: everything runs on the in-memory **mock driver**
> (`getPosDriver()`), every payment rail **simulates** settlement when its keys
> are absent, and the full automated test suite + production build pass with
> **no environment variables**. No live services are wired in this phase.

---

## 1. Tenant-isolation audit (highest priority)

Tenant data leakage is the #1 risk for a multi-tenant SaaS. Isolation is enforced
at **two layers**, both with automated tests:

| Layer | Mechanism | Test |
|---|---|---|
| **Database (authoritative)** | Postgres **RLS** on every tenant table, keyed to the `memberships` table; `FORCE ROW LEVEL SECURITY` so even the table owner is default-denied; platform-admin bypass via `is_platform_admin()`. | `supabase/tests/rls_isolation.sql` (+ `run-rls-isolation.sh`, optional non-blocking CI job) |
| **Application** | Every `PosDriver` read is scoped by `tenant_id` (+ `location_id`). No call site issues an un-scoped query. | `src/lib/db/tenant-isolation.test.ts` (runs in required CI) |

The app-layer test proves a second tenant created via `createTenant` cannot have
its orders, menu, inventory, reports, or locations surfaced through another
tenant's driver calls. The SQL test proves a member of tenant A cannot read or
write tenant B's tenants/locations/memberships/users, that a blocked cross-tenant
write leaves no row, and that a platform admin sees everything.

**Go-live dependencies:**
- [ ] Provision Supabase; apply `supabase/migrations/*` and confirm RLS is ON +
FORCED on every table (existing + future orders/payments/etc. tables get
the same `memberships`-keyed policies before they hold tenant data).
- [ ] Wire Supabase Auth so `auth.uid() == public.users.id` (the RLS assumption).
- [ ] Confirm the **service-role key is never** used for tenant-scoped
reads/writes without an explicit `tenant_id` filter (server code runs as
the `authenticated` role through PostgREST).
- [ ] Run `run-rls-isolation.sh` against the live DB; expect "RLS isolation test PASSED".

---

## 2. Idempotency & double-charge guarantees

See `docs/IDEMPOTENCY_REVIEW.md` for the full review. Summary of the guarantees,
each covered by tests:

- **Orders** — `createOrder` is an idempotent **upsert-by-client-UUID**. The
offline queue keys entries by the order UUID and the flush POSTs to
`/api/orders`; a double-flush / reconnect-retry returns the existing order and
assigns the order number **once**. (`offline/sync.test.ts`, `offline/queue.test.ts`)
- **Payments** — every tender carries a client UUID that is **both** the rail
idempotency key **and** the payment row primary key. `takePayment` returns the
existing tender on a repeat id → **no second charge**. (`payments/service.test.ts`)
- **Order settlement** — the order flips to `paid` only when **settled** tenders
(captured/authorized) cover the total; pending crypto counts toward the
displayed balance but **not** toward `paid`. Split payments drive the balance
to zero across tenders. (`payments/service.test.ts`)
- **Refund/void** — a tender is marked `refunded` when fully refunded; the order
flips to `refunded` only when **all** tenders are refunded; a partial refund
does not. (`payments/service.test.ts`)
- **Deliveries** — `dispatchDelivery` is idempotent on the order id.
- **End-of-day** — `closeBusinessDay` is idempotent: re-closing returns the
frozen Z-report snapshot. (`db/mock-drawer.test.ts`)

**Go-live dependencies:** forward the same client UUID as the Stripe
`Idempotency-Key` (already wired in the rails) and as the crypto deposit
reference; verify webhook handlers are idempotent on the charge id.

---

## 3. Offline / store-and-forward risk notes

- The terminal **always-queues** placed orders in IndexedDB (Dexie) keyed by the
order UUID, then flushes idempotently — safe across reconnect + interval +
manual triggers (the flush is re-entrant-guarded).
- **Offline card** uses Stripe Terminal **store-and-forward** (reader secure
element queues the txn, forwards on reconnect). Known trade-offs, surface them
in UX: per-txn cap + time window, **card-present only**, **no offline refunds**,
and the **merchant carries decline risk**. Crypto + online card need connectivity.
- Mitigations in place: idempotent upsert end-to-end (no dupes on replay), clear
pending-sync count in the terminal, and a balance model that won't ask a
cashier to collect twice while a tender is in flight.

---

## 4. Connect / PCI posture

- **We never custody tenant card revenue.** Card charges (Terminal + online) are
created on the tenant's **Stripe Connect connected account**; funds settle to
**their** account. Our revenue is (1) **subscription** via Stripe Billing and
(2) a per-order **`application_fee`** off card charges.
- **We never store a PAN / card data.** Card entry is handled by Stripe
(Terminal hardware secure element / Stripe.js + PaymentIntents). No card number
touches our servers or DB → **SAQ-A-level** scope. The `payments` table stores
only rail-native ids (PaymentIntent id, charge id), amounts, and status.
- The platform fee is **disclosed to tenants at onboarding** and is **card-only**
(cash + crypto carry no `application_fee` in v1). Fee math is clamped to the
charge amount and proven in `payments/fees.test.ts`.

**Go-live dependencies:** complete Stripe **Connect KYC onboarding** per tenant
(Stripe handles identity/KYC); confirm payouts route to the tenant's bank; never
log card data; keep the service-role key server-only.

---

## 5. Crypto finality handling

- Quote + settle in **USDC** (USD-pegged → no FX risk for USD orders). USDC has 6
decimals; cents → base units is integer math (no float drift).
- **Onchain (Base):** an order is **not** marked paid until the tx reaches
`REQUIRED_CONFIRMATIONS` (3). The tender stays `pending` until then; the watcher
(`status`) / webhook flips it to `captured`, which then settles the order.
Reverted txs → `failed`. (`payments/service.test.ts` covers pending-not-paid →
confirm-settles.)
- **Coinbase Commerce:** charge stays `pending` until a signature-verified
`charge:confirmed`/`charge:resolved` webhook.
- Pending crypto counts toward the **displayed** balance (so the cashier isn't
asked to collect twice) but never toward `paid`.

**Go-live dependencies:** set `BASE_RPC_URL` (+ Privy for per-order deposit
addresses) and/or `COINBASE_COMMERCE_API_KEY` + webhook secret; handle
under/overpay + quote expiry; decide platform-fee-on-crypto (v1: subscription-only).

---

## 6. Backups & data durability (Supabase)

- Use Supabase **Point-in-Time Recovery (PITR)** on the production project (paid
tier) for continuous WAL backups; otherwise rely on daily automated backups.
- [ ] Enable PITR (or daily backups) on the prod project; document RPO/RTO.
- [ ] Periodically test a **restore** into a scratch project.
- [ ] Treat `supabase/migrations/*` as the source of truth; never hand-edit prod.
- [ ] Keep the `service_role` key in a secret manager; rotate on exposure.

---

## 7. Observability

Scaffolding added this phase, all **env-optional** and no-op-safe:

- **Structured logging** (`src/lib/observability/logger.ts`) — single-line JSON to
stdout/stderr, `LOG_LEVEL`-aware (default `info`), with per-request child
loggers carrying a correlation id.
- **Request/trace ids** (`src/lib/observability/trace.ts`) — reuse upstream
`x-request-id` / `x-trace-id` / W3C `traceparent`, else mint one; echoed on the
response. Wired into `/api/orders` (representative) + the health/ready endpoints.
- **Error-tracking seam** (`src/lib/observability/errors.ts`) — `captureError`
always emits a structured error log and routes to an external tracker (Sentry)
**only when `SENTRY_DSN` is set**; a no-op otherwise (SDK intentionally not yet
a dependency).
- **Health/readiness** — `/api/health` (liveness) and `/api/ready` (readiness:
probes the driver, reports training-mode + 503 if a future dependency is down).

**Go-live dependencies:** add the Sentry SDK behind `SENTRY_DSN`; point a log
drain at the JSON logs; alert on `/api/ready` 503s + error-rate.

---

## 8. Training / demo mode

`src/lib/demo/mode.ts` formalizes a `TRAINING_MODE` flag (also implied whenever
the **mock driver** is active — the current default, where nothing is real). In
training mode, payments simulate and orders are disposable, so a tenant can train
staff without real charges. `/api/ready` and `demoModeInfo()` expose a badge
(`"TRAINING MODE — orders and payments are simulated and not charged."`).

**Go-live dependencies:** surface the training badge in the terminal/admin
chrome; ensure flipping `TRAINING_MODE` off (with live keys) is the explicit
"now taking real orders" switch.

---

## 9. Environment variable inventory

Full annotated list in `.env.example` (all blank — **public repo, never commit
secrets**). Everything is optional; the app builds + the suite passes with none.

| Area | Vars | Unset behaviour |
|---|---|---|
| Supabase | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL` | Mock driver |
| Stripe (Connect/Terminal/online) | `STRIPE_SECRET_KEY`, `STRIPE_*PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_CONNECT_CLIENT_ID` | Simulated charges + Connect |
| Platform fee | `PLATFORM_FEE_BPS`, `PLATFORM_FEE_FLAT_CENTS` | 250 bps + 10¢ |
| Stripe Billing | `STRIPE_PRICE_STARTER/_PRO/_MULTI`, `STRIPE_BILLING_WEBHOOK_SECRET` | Simulated subscriptions |
| Crypto onchain | `BASE_RPC_URL`, `NEXT_PUBLIC_PRIVY_APP_ID`, `PRIVY_APP_SECRET`, `USDC_*`, `CRYPTO_PAY_TO_ADDRESS` | Simulated auto-confirm |
| Crypto Coinbase | `COINBASE_COMMERCE_API_KEY`, `COINBASE_COMMERCE_WEBHOOK_SECRET` | Simulated auto-confirm |
| KDS | `KDS_POLL_INTERVAL_MS`, `KITCHEN_PRINTER_URL` | Polling realtime + browser print |
| Delivery | `DOORDASH_DEVELOPER_ID`, `DOORDASH_KEY_ID`, `DOORDASH_SIGNING_SECRET`, `DOORDASH_BASE_URL` | In-house real, DoorDash simulated |
| Observability | `LOG_LEVEL`, `SENTRY_DSN` | info logs, error-tracking no-op |
| Training | `TRAINING_MODE` | Mock driver ⇒ training on |
| App | `NEXT_PUBLIC_APP_URL` | Request origin |

---

## 10. Go-live checklist (depends on the final live-wiring phase)

- [ ] Provision Supabase; apply migrations; enable + verify **RLS/FORCE** on all
tenant tables; run the RLS isolation harness green.
- [ ] Wire Supabase Auth (`auth.uid() == users.id`); swap `getPosDriver()` to the
Supabase driver (single switch in `src/lib/db/client.ts`, no call-site changes).
- [ ] Enable **PITR/backups**; test a restore.
- [ ] Stripe: live keys + **Connect onboarding per tenant** (KYC); Billing Prices
per tier; verify webhooks (payments, Connect, Billing) signature-checked.
- [ ] Crypto: `BASE_RPC_URL` (+ Privy) and/or Coinbase keys + webhook secret;
confirm confirmation thresholds + under/overpay handling.
- [ ] DoorDash Drive keys (optional); confirm in-house dispatch.
- [ ] Observability: Sentry behind `SENTRY_DSN`; log drain; alerts on `/api/ready`.
- [ ] Flip `TRAINING_MODE` off for real stores; confirm the training badge clears.
- [ ] Hardware field test (reader, printer, drawer, tablet) — separate from this
code phase.
- [ ] Re-run the full Vitest suite + production build with the live env; both green.
Loading
Loading