# Remote Check-In (QR Web Lane) — Design

**Status:** Draft · 2026-06-15
**Repos:** `alqove-web` (public customer UI) · `alqove-api` (customer + POS APIs, SMS) · `alqove-pos` (staff desktop app — *consumes* the API; not built here)
**Predecessors:** Kiosk Plan 1 (`kiosk_devices`, `AuthenticateKioskDevice`, principal-scoped idempotency, `users.phone`/nullable-email/`loyalty_points`) · Kiosk Plan 2 (`buys`, `loyalty_transactions`, `BuyIntakeService`, `KioskCustomerResolver`, `LoyaltyWriter`, `BuyStatus`/`BuySource` enums). **This spec assumes Plan 2 is merged to `alqove-api` `main`.**
**Successors:** Full POS buy-lifecycle API (sorting → quoted → … beyond the minimal slice here); appointment scheduling; loyalty member portal.

---

## Purpose

The buy kiosk (Plans 1–4) requires a store to own a physical intake terminal. Stores without one — or customers who'd rather not queue at a machine — have no remote way to start a sell/buy. This adds a **secondary, web-based check-in lane**: a customer scans a store's QR code with their own phone, fills a mobile-optimized form, and is registered into that store's buy queue before they even walk in.

This lane is deliberately **lightweight**: it gets the customer *in line* and captures just enough to print container labels and prepare. The legally-sensitive parts of a buy — driver's license capture, the binding terms signature — happen **in-store**, where staff verify identity in person. The data model already anticipates this: `BuySource::QrCode` and `BuyStatus::RemoteCheckIn` exist (Plan 2) but are unused.

**The flow, end to end:**

1. Customer scans the store QR → `checkin.alqove.com/c/{token}`.
2. Mobile form: name, phone, **number of containers** + a short description, loyalty opt-in, transactional-SMS consent; optional "log in to attach to your Alqove account."
3. Submit → a `Buy { source: qr_code, status: remote_check_in }` is created. The customer sees a confirmation with a **short check-in code** and an **unguessable signed status link** (also attached to their account if logged in).
4. Customer brings containers to the store and **leaves**. Staff (in `alqove-pos`) pull the check-in up by **phone or code**, print container labels, capture DL + signature in person, and transition the buy `remote_check_in → queued`. Loyalty is granted at this point.
5. The store processes the buy. On reaching a **terminal state** (`accepted` / `no_buy` / `declined`), the API sends a **Twilio SMS**: "your buy is complete — view details: `<link>`" (no dollar amounts). The customer returns to collect payment / unsold items.
6. Abandoned check-ins **auto-void** after a per-store TTL; their PII is purged after a retention window.

## Why a separate lane (not "kiosk over the web")

The kiosk's entire trust model is *"the caller holds a provisioned device token."* A public URL has none of that — it is anonymous, hostile, internet-facing traffic. Every security control below exists because we cannot assume a trusted principal, a supervised environment, or a verified human. We therefore **reuse the kiosk's domain services** (`BuyIntakeService`, `KioskCustomerResolver`, `LoyaltyWriter`, idempotency) but wrap them in an entirely different **principal + abuse-protection** layer, and we **defer** the sensitive capture and the loyalty grant to the in-store step.

## User model

- **Customer (anonymous or logged-in).** On a phone, on cellular, possibly mid-parking-lot. Wants a fast form and a way to check status later. Touches the system a handful of times per year. **Highest-volume, least-trusted actor.**
- **Store staff (via `alqove-pos`).** Pull up check-ins, print labels, convert to a real buy, drive it to completion. Authenticated as a **store device**. Trusted, on-premises.
- **Store (config owner).** Toggles the lane on/off, sets TTL and status-visibility preferences, owns the QR token.

## Architecture & project boundary

| Concern | Project | Notes |
|---|---|---|
| Public check-in form, status page | `alqove-web` | New `(checkin)` route group; mobile-first; edge-rendered shell |
| Customer-facing endpoints (`/v1/checkin/*`) | `alqove-api` | Public, no device token; Turnstile + layered rate limits |
| POS-facing endpoints (`/v1/pos/*`) | `alqove-api` | Provisioned store-device key (kiosk-device pattern) |
| Twilio completion SMS | `alqove-api` | New notification channel, gated on `opt_txn` |
| Staff UI | `alqove-pos` | **Out of scope here** — consumes the API contract this spec defines |

---

## Decisions

| # | Decision | Choice |
|---|---|---|
| D1 | Lane weight | **Lightweight remote check-in.** Collect name + phone + containers + opt-ins only. DL number + binding signature + terms are captured **in-store** (POS), never on the public web. |
| D2 | Phone verification | **None (no OTP).** No inbound SMS surface. Phone ownership is implicitly verified in-store (staff see the person). |
| D3 | Loyalty grant timing | **Deferred.** No points at remote check-in. Granted by the POS `remote_check_in → queued` conversion via the existing `LoyaltyWriter` (one-join ledger marker prevents double-grant). |
| D4 | Store identification | **Static, opaque, signed per-store token** in a new `store_checkin_links` table. Non-enumerable; rotatable/revocable without redeploy. |
| D5 | Identity | **Optional marketplace login.** Anonymous → match-or-create by phone (`KioskCustomerResolver`). Logged-in → attach to that `User`, skip create, prefill name/phone. |
| D6 | Item capture | **Containers, not items.** `container_count` (capped 1–20) + short `container_description`. Drives label printing. |
| D7 | Status visibility | **Per-store config:** `confirmation_only` ↔ `live`. Live = **slow polling (30–60s)**, no websockets. |
| D8 | Status re-access | **Unguessable signed per-buy status link**, persisted on-device and included in the completion SMS. Also reachable under the customer's account if logged in. |
| D9 | Staff pull-up | **Phone OR short check-in code.** Code is unique among a store's **active** (non-terminal) check-ins, recycled after close. |
| D10 | Expiry | **TTL auto-void only** (per-store configurable). No "I'm here" tap. Per-store **pause toggle**; check-ins accepted anytime (closed-store messaging is best-effort). |
| D11 | Duplicate open check-in (same phone, same store) | **Allow, flag to staff** (legit repeat trips). Exact-replay still deduped by idempotency. |
| D12 | Retention | **Configurable TTL** + **purge/anonymize abandoned-check-in PII after N days.** |
| D13 | POS auth | **Per-device store API key** (kiosk-device pattern; new `pos_devices` table + `AuthenticatePosDevice`). No per-staff attribution in v1. |
| D14 | API scope | **Minimal lifecycle slice:** list/look-up check-ins, mark labels printed, `remote_check_in → queued` (with in-store capture), and a terminal `complete` action. Full lifecycle deferred. |
| D15 | Completion SMS | **Twilio**, on **any terminal state**. Minimal copy + signed status link, **no dollar amounts**, STOP opt-out. Gated on `opt_txn`; if absent, status-link only. |
| D16 | Bot protection | **Cloudflare Turnstile** (invisible-first), verified server-side on the create endpoint. |
| D17 | Labels | API returns **structured data** (code, name, `container_count`); **POS renders/prints** locally. No server-side label PDF. |
| D18 | QR URL | Dedicated subdomain **`checkin.alqove.com/c/{token}`** for edge-cache + cookie isolation from the main app. |

---

## Data model (alqove-api)

### `buys` — additive changes
Plan 2 created `buys`. This adds:

| column | type | notes |
|---|---|---|
| `container_count` | unsignedSmallInteger, nullable | 1–20 validated; null for kiosk buys |
| `container_description` | string(255), nullable | short free text |
| `checkin_code` | string(16), nullable | human-friendly, e.g. `A7X-4421`; unique among a store's non-terminal buys (partial/app-enforced) |
| `status_token` | char(43), nullable, unique | 32-byte base64url, unguessable; powers the signed status link |
| `duplicate_of_buy_id` | foreignUuid → buys, nullable | set when another open check-in for the same `(store_id, phone)` exists at create time; advisory for staff |
| `checked_in_at` | timestamp, nullable | when the public submission landed |

**Conditional requiredness:** `dl_number`, `signature_state`/signature media, and `terms_version` are **not required** for a `source = qr_code` buy at creation — they are populated at the POS conversion step. `BuyRequest`-style validation must branch on lane.

### `store_checkin_links` — new
| column | type | notes |
|---|---|---|
| `id` | uuid (HasUuid) | |
| `store_id` | foreignUuid → stores | |
| `token` | string(64), unique | opaque random; the value embedded in the QR |
| `is_active` | boolean, default true | |
| `rotated_at`, `revoked_at` | timestamp, nullable | rotation supersedes; revoke 410s old links |
| timestamps | | |

Resolution: `{token}` → active link → store. Revoked/inactive → **410 Gone**. Then the store-active check (suspended / soft-deleted / **paused**) → **423 Locked** with friendly copy.

### `store_settings` — additive changes
(Plan 2 added `join_points`/`promo_points`/`minutes_per_buy`.)

| field | type | notes |
|---|---|---|
| `checkin_enabled` | boolean, default false | master switch for the lane |
| `checkin_paused` | boolean, default false | staff "we're slammed" toggle → 423 |
| `checkin_status_visibility` | string enum (`confirmation_only`\|`live`) | D7 |
| `checkin_ttl_minutes` | unsignedInteger | auto-void window (D10) |
| `checkin_pii_retention_days` | unsignedInteger | purge window (D12) |

### `pos_devices` — new (mirrors `kiosk_devices`)
`id`, `store_id`, `name`, `token_hash`, `last_seen_at`, `revoked_at`, timestamps. Provisioned via `pos:provision-device` / `pos:revoke-device` artisan commands (mirroring the kiosk commands). *(Alternative considered: add a `type` column to `kiosk_devices` and unify into `store_devices`; deferred to avoid touching merged kiosk code — flagged for review.)*

---

## Security model

The public lane assumes anonymous, adversarial traffic. Controls, outermost → innermost:

1. **Edge / WAF.** Cloudflare in front of `checkin.alqove.com`: volumetric + bad-bot filtering before Laravel.
2. **Bot challenge (D16).** Turnstile token required by `POST /v1/checkin/{token}/requests`; verified server-side against Cloudflare. Missing/invalid → 403.
3. **Layered rate limits** (named limiters, mirroring `throttle:kiosk-lookup`'s multi-key style):
   - `checkin-branding`: per-IP + per-token (cheap GET).
   - `checkin-create`: per-IP **and** per-phone-HMAC **and** per-token **and** a global ceiling.
   - `checkin-status`: per-`status_token` + per-IP.
   HMAC-SHA-256(phone, `app.key`) reused from `KioskCustomerResolver` — raw phone never logged.
4. **Non-enumerable store identity (D4).** QR carries an opaque token, never `store_id`. Rotatable/revocable.
5. **No loyalty to farm (D3) + no inbound SMS (D2).** The two highest-value abuse incentives are removed structurally rather than policed.
6. **PII minimization (D1).** DL + signature never traverse the public endpoint. Public lane PII is just name + phone + container description — purged after retention (D12).
7. **Unguessable status link (D8).** 32-byte `status_token`; the status endpoint returns only that buy's status/position/containers. No buy is reachable by guessing IDs.
8. **Idempotency (reused).** Client generates a UUID `idempotency_key` (sent in body + `Idempotency-Key` header, equality-enforced as in Plan 2). Compound `unique(store_id, idempotency_key)` + `request_fingerprint`: true replay → 200 same buy; key-reuse-with-different-body → 409. Handles double-submit / back-button on flaky mobile.
9. **POS isolation (D13).** `/v1/pos/*` requires a provisioned device key; a leaked public link can never reach staff endpoints.
10. **Optional auth.** If a Sanctum bearer is present and valid on create, the buy attaches to that `User`; otherwise phone match-or-create. The public endpoint must treat the bearer as *optional* and never 401 for its absence.

**TCPA / consent.** `opt_txn` (transactional) gates the completion SMS; `opt_promo` (marketing) is separate. STOP/HELP handled by Twilio. If `opt_txn` is false, the customer relies on the status link only.

---

## API surface (alqove-api)

### Customer-facing — `/v1/checkin/*` (public; `resolve.checkin.store` + store-active + rate limits)
- `GET /v1/checkin/{token}` → store branding (name, logo, hours, status-visibility mode, current `terms_version`, `checkin_enabled`/paused state). Cacheable.
- `POST /v1/checkin/{token}/requests` → create the `remote_check_in` buy. Body: `idempotency_key`, `phone`, `first_name`, `last_name`, `container_count`, `container_description?`, `opt_loyalty`, `opt_txn`, `opt_promo`, `turnstile_token`. Optional `Authorization: Bearer`. Middleware: `idempotency`, `throttle:checkin-create`, `verify.turnstile`. Returns the `checkin_code` + `status_token` (→ status URL). **201** new / **200** replay / **409** key reuse / **423** paused-or-suspended / **410** dead link.
- `GET /v1/checkin/status/{status_token}` → status, queue position (via `KioskQueueService`) + ETA (`minutes_per_buy`) when store is in `live` mode, container summary. `throttle:checkin-status`.

### POS-facing — `/v1/pos/*` (`pos.device` + `pos.store.active`)
- `GET /v1/pos/checkins` → store's open check-ins (`remote_check_in`), with code, name, containers, `duplicate_of_buy_id` flag, `checked_in_at`.
- `GET /v1/pos/checkins/lookup?phone=` or `?code=` → resolve a specific check-in.
- `POST /v1/pos/checkins/{buy}/labels-printed` → mark labels printed (advisory).
- `POST /v1/pos/checkins/{buy}/convert` → `remote_check_in → queued`; body carries in-store-captured `dl_number?`, `terms_version`, `signature_png_base64`. **Triggers the loyalty grant** (reusing `LoyaltyWriter`, one-join guarded). 409 if already converted.
- `POST /v1/pos/buys/{buy}/complete` → set a **terminal** status (`accepted`/`no_buy`/`declined`); **fires the Twilio completion SMS** (gated on `opt_txn`). Idempotent on terminal state.

All new request/response shapes land in `api/contracts/openapi.yaml` so `@alqove/types` regenerates for the web client.

### Service refactor
`BuyIntakeService::submit(KioskDevice $device, BuyDto $dto)` is **device-coupled** and grants loyalty + requires a signature inline. Generalize to:

```
submit(string $storeId, BuySource $source, BuyStatus $initialStatus, BuyDto $dto, bool $grantLoyalty, bool $requireSignature): array
```

- Kiosk: `(…, Kiosk, Queued, dto, grantLoyalty: true, requireSignature: true)` — unchanged behavior.
- Web check-in: `(…, QrCode, RemoteCheckIn, dto, grantLoyalty: false, requireSignature: false)`.
- POS convert: transitions the existing buy to `Queued`, attaches signature, and calls the loyalty grant.

Keep the existing idempotency/fingerprint/transaction/queue-race handling intact.

---

## Web implementation (alqove-web)

- **New route group** `web/src/app/(checkin)/c/[token]/` — public layout, **not** gated by the `localStorage` auth token in `lib/api.ts`; an optional "log in" affordance attaches the session when present. Served on `checkin.alqove.com` (host routing / deploy config).
- **Form** — mobile-first multi-step wizard (phone → details/containers → review), large touch targets, `inputmode=tel`, Turnstile widget. Client **generates + persists** the `idempotency_key` (UUID) before first submit so refresh/retry replays safely. Confirmation screen shows the code + status link + "show staff your phone."
- **Status page** — `/c/{token}/s/{status_token}`; polls `GET /v1/checkin/status/{status_token}` every 30–60s in `live` mode, static confirmation otherwise.
- **api-client** — new `createCheckinEndpoints` (public; per-call optional auth) added to `@alqove/api-client`; types from regenerated `@alqove/types`.

### Optimization (customer-facing, slow networks)
- Edge/SSR the branding shell for instant first paint; lazy-load the interactive form to keep initial JS tiny.
- CDN-cache QR images + store branding (`GET /v1/checkin/{token}` is cacheable with short TTL + revalidation on pause/rotate).
- Slow polling (no websocket fan-out); minimal payloads; no PII in URLs.

---

## Scheduled jobs (reuse Layer 11 cron infra)
- **Auto-void:** void `remote_check_in` buys older than the store's `checkin_ttl_minutes`.
- **PII purge:** anonymize/delete name/phone/container fields on voided or abandoned check-ins past `checkin_pii_retention_days`.

---

## Edge cases & gotchas
- **Ghost check-ins** (check in, never show) → TTL auto-void (D10) keeps the POS queue honest.
- **Two people, one phone** / repeat trips → duplicate allowed + flagged (D11); short code disambiguates at the counter (D9).
- **Phone typos / non-US numbers** → `PhoneNormalizer` is US-only; reject gracefully with a clear message.
- **Loyalty double-grant across lanes** → grant only at POS conversion, guarded by the existing one-join ledger marker.
- **Store paused/suspended mid-flow** → re-checked on submit → 423 with friendly copy.
- **Dead/rotated QR** → 410; signage rotation is an operational step.
- **`opt_txn` declined** → no SMS; customer uses the status link.
- **Status-link sharing** → link reveals only that buy's non-financial status; no dollar amounts anywhere in SMS/status.
- **Accessibility** → public consumer surface ⇒ real ADA exposure; WCAG-AA form semantics required.
- **Closed-store check-in** → accepted anytime per D10; branding surfaces hours; consider future "appointment" use of the existing enum.

## Open questions (for review before the plan)
1. `pos_devices` standalone vs unifying with `kiosk_devices` via a `type` column (`store_devices`). Standalone proposed; unification is cleaner long-term.
2. Exact `checkin_code` format + collision/recycle policy (proposed: 3 alpha + 4 digits, ambiguous chars removed, unique among store's non-terminal buys).
3. Should `convert` (in-store) reuse the *same* `terms_version`/signature validation as the kiosk `BuyRequest`, or a POS-specific request? (Leaning: share the rules.)
4. Do we need store **hours** as first-class data now (for branding + "appointment"), or is the pause toggle + free-text hours enough for v1?
5. SMS copy + sender registration (10DLC) ownership.

## Out of scope
DL/signature on the public web; OTP; full POS buy lifecycle beyond the minimal slice (D14); appointment scheduling; loyalty member portal; international phones; server-side label PDFs; per-staff POS attribution.

## Revision history
- **2026-06-15 v1** — initial design from the brainstorm; 18 decisions locked (D1–D18). Assumes kiosk Plan 2 merged.
