# Remote Check-In (QR Web Lane) — API Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Land the alqove-api surface for the web QR remote-check-in lane: the public, unauthenticated `/v1/checkin/*` endpoints (store branding, idempotent check-in submit, signed status read) and the POS-facing `/v1/pos/*` minimal lifecycle slice (list/look-up check-ins, mark labels printed, convert `remote_check_in → queued` with in-store capture + loyalty grant, terminal `complete` that fires a Twilio SMS). Reuses Plan 2's `buys`, `KioskCustomerResolver`, `LoyaltyWriter`, `KioskQueueService`, idempotency, and the `BuySource`/`BuyStatus` enums (`QrCode`/`RemoteCheckIn` already exist).

**Architecture:** A new `app/Modules/Checkin` module owns the public lane; a new `app/Modules/Pos` module owns the staff lane. The public lane authenticates as a **store check-in link** (opaque signed token → store), NOT a device or user; the POS lane authenticates as a provisioned **store device** (kiosk-device pattern). `BuyIntakeService` is generalized off `KioskDevice` so kiosk + web share one creation path. Loyalty + signature + DL are **deferred** on the web lane and applied at the POS convert step. Full design context: `alqove-web/web/docs/superpowers/specs/2026-06-15-remote-checkin-qr-lane-design.md` — this plan executes those decisions (D1–D18), it does not relitigate them.

**Tech Stack:** Laravel (Sail), Sanctum (optional bearer on the public submit), spatie/laravel-medialibrary (signature, existing), spatie/laravel-permission (`buyer` role), `laravel-notification-channels/twilio` (NEW — completion SMS), Cloudflare Turnstile (server-side verify via HTTP). PHPUnit/Pest, Pint, PHPStan. Run via Sail from repo root.

**Branch:** `feature/remote-checkin-api` off `main` (Plan 2 already merged — `buys`, resolver, enums, intake all present).

**Conventions (from `api/CLAUDE.md` + kiosk Plan 1/2 precedent):**
- All PHP files start `declare(strict_types=1);`. One class per file. Typed properties + return types.
- Modules under `app/Modules/{Module}/` own Controllers/Requests/Resources/Services/Tests/routes.php/README.md. Shared models in `app/Models/`.
- Backed-string enums in `app/Support/Enums/`. `HasUuid` trait for UUID PKs. Money = `unsignedInteger` cents.
- Migrations: `YYYY_MM_DD_NNNNNN_descriptive_name` (use the `2026_06_16_` prefix).
- Feature tests in `tests/Feature/{Module}/`, unit in `tests/Unit/{Module}/`. Factories, never raw inserts. Seed `RoleAndPermissionSeeder` in feature `setUp`.
- Commit style: `type(scope): subject` (e.g. `feat(checkin):`, `feat(pos):`, `test(checkin):`).

**⚠️ Test-DB portability (LEARNED FROM PLAN 2):** `phpunit.xml` defaults to **SQLite `:memory:`** but CI (`.github/workflows/ci.yml`) runs **PostgreSQL = production**. SQLite masks two whole classes of bug: (1) constraint violations don't abort the transaction; (2) `uuid` columns accept arbitrary text. **Every task that touches constraint handling, raw id lookups, or casting MUST be verified against Postgres locally:**
```bash
docker compose exec -T -e DB_CONNECTION=pgsql -e DB_HOST=pgsql -e DB_DATABASE=testing \
  -e DB_USERNAME=sail -e DB_PASSWORD=password laravel.test php artisan test --filter=<NAME>
```
Run the final sweep on Postgres, not just SQLite.

**Pre-existing issues (NOT your changes; do not chase):** Typesense `Search`/`Items` indexing tests when no `x-typesense-api-key` in the exec env; ~826 pre-existing larastan level-6 errors (no baseline). `PayoutServiceScheduleTest` is now fixed (clock pinned) — keep it green.

**Test command (canonical):**
```bash
docker compose exec -T laravel.test php artisan test --filter=<TEST_NAME>
```

**Revision history:**
- **v1 (2026-06-16)** — initial 12-task plan from the design spec.
- **v2 (2026-06-16)** — incorporate first codex plan-review (2 BLOCKERs, 5 IMPORTANTs, 2 NITs), all verified against the merged Plan 2 code:
  - **B1** — `buys.terms_version` is `NOT NULL` (no default); a `qr_code` buy has no terms → insert 500s. Task 1 Step 2a now **requires** relaxing it to nullable (Laravel 11 `->change()`).
  - **B2** — `BuyDto` typed `signaturePngBase64` non-null + `attachSignature` unconditional → the signature-less web path can't build a DTO. Task 3 Step 2b makes signature/dl/terms nullable and `attachSignature` a no-op on empty.
  - **I1** — schedule registration moved to `bootstrap/app.php` `->withSchedule()` (verified: that's where this app's crons live), not `routes/console.php`. Modify-list updated.
  - **I2** — optional-login mechanism made concrete: resolve `auth('sanctum')->user()` on the non-auth route; ignore an absent/invalid bearer (never 401). Task 7 Step 3.
  - **I3** — dropped the non-portable Postgres partial-unique index on `checkin_code`; uniqueness is app-enforced in `generateCheckinCode` + a plain index. Task 1 Step 2b.
  - **I4** — loyalty-grant timing pinned to `convert` (`→queued`) per spec D3; `no_buy`/`declined` keep the join. Task 3 Step 3.
  - **I5** — status endpoint maps `KioskQueueService::positionFor()`'s `etaMinutes`→`estimated_wait_minutes` and gates position/eta on `live` visibility (the service always computes eta). Task 7 Step 4.
  - **N1** — documented that idempotency dedupe rests on the durable `unique(store_id, idempotency_key)`, not the Authorization-scoped cache. Task 7 Step 5.
  - **N2** — `pos.store.active` must stay green when public check-ins are paused (only 423 on suspended/deleted store). Task 8 Step 2.

---

## File structure overview (relative to `api/`)

**Create:**
```
config/checkin.php                                  # ttl, retention, turnstile, rate-limit defaults
app/Models/StoreCheckinLink.php
app/Models/PosDevice.php
app/Http/Middleware/ResolveCheckinStore.php         # token → link → store on $request
app/Http/Middleware/EnsureCheckinStoreAcceptable.php # 423 suspended/deleted/paused/disabled
app/Http/Middleware/VerifyTurnstile.php
app/Http/Middleware/AuthenticatePosDevice.php
app/Support/Services/TurnstileVerifier.php
app/Modules/Checkin/CheckinServiceProvider.php      # Request macros + rate limiters
app/Modules/Checkin/Controllers/CheckinBrandingController.php
app/Modules/Checkin/Controllers/CheckinRequestController.php
app/Modules/Checkin/Controllers/CheckinStatusController.php
app/Modules/Checkin/Requests/SubmitCheckinRequest.php
app/Modules/Checkin/Resources/CheckinBrandingResource.php
app/Modules/Checkin/Resources/CheckinAcceptedResource.php
app/Modules/Checkin/Resources/CheckinStatusResource.php
app/Modules/Checkin/routes.php
app/Modules/Checkin/README.md
app/Modules/Pos/Controllers/PosCheckinController.php
app/Modules/Pos/Controllers/PosBuyController.php
app/Modules/Pos/Requests/ConvertCheckinRequest.php
app/Modules/Pos/Requests/CompleteBuyRequest.php
app/Modules/Pos/Resources/PosCheckinResource.php
app/Modules/Pos/routes.php
app/Modules/Pos/README.md
app/Modules/Notifications/Notifications/BuyCompletedNotification.php
app/Console/Commands/ProvisionPosDevice.php
app/Console/Commands/RevokePosDevice.php
app/Console/Commands/AutoVoidStaleCheckins.php
app/Console/Commands/PurgeCheckinPii.php
database/migrations/2026_06_16_000001_add_remote_checkin_fields_to_buys_table.php
database/migrations/2026_06_16_000002_create_store_checkin_links_table.php
database/migrations/2026_06_16_000003_add_checkin_fields_to_store_settings_table.php
database/migrations/2026_06_16_000004_create_pos_devices_table.php
database/factories/StoreCheckinLinkFactory.php
database/factories/PosDeviceFactory.php
tests/Feature/Checkin/*  tests/Feature/Pos/*  tests/Unit/Checkin/*
```

**Modify:**
```
app/Modules/Kiosk/Services/BuyIntakeService.php     # decouple from KioskDevice; extract grant/convert
app/Modules/Kiosk/Controllers/BuyController.php      # call refactored submit()
app/Models/Buy.php                                   # new fillable/casts, checkin_code + status_token gen
app/Models/StoreSettings.php                         # new fields
app/Modules/Stores/Requests/UpdateStoreSettingsRequest.php   # expose checkin toggles
app/Modules/Stores/Resources/StoreSettingsResource.php
config/services.php                                  # twilio + turnstile creds
routes/api.php                                       # require Checkin + Pos routes
bootstrap/app.php                                    # middleware aliases + withSchedule(auto-void, purge-pii)
composer.json                                        # require laravel-notification-channels/twilio
api/contracts/openapi.yaml                           # all new paths (regenerates @alqove/types)
```

---

## Task 1 — Schema: `buys` additions + `store_checkin_links` + `store_settings` + `pos_devices`

**Files:** the four `2026_06_16_*` migrations; `StoreCheckinLink`, `PosDevice` models + factories; `Buy`, `StoreSettings` model updates.

- [ ] **Step 1 (test, Postgres):** `tests/Feature/Checkin/CheckinSchemaTest.php` — assert a `Buy` can be created with `container_count`/`container_description`/`checkin_code`/`status_token`/`duplicate_of_buy_id`/`checked_in_at`; a `qr_code` buy persists with `dl_number`/`terms_version` null; `store_checkin_links` enforces `unique(token)`; `store_settings` has the new boolean/int columns with documented defaults; `pos_devices` mirrors `kiosk_devices`. Run with `-e DB_CONNECTION=pgsql`.
- [ ] **Step 2:** Migration `...000001_add_remote_checkin_fields_to_buys_table`:
  ```php
  $t->unsignedSmallInteger('container_count')->nullable();
  $t->string('container_description', 255)->nullable();
  $t->string('checkin_code', 16)->nullable();
  $t->char('status_token', 43)->nullable()->unique();
  $t->foreignUuid('duplicate_of_buy_id')->nullable()->constrained('buys')->nullOnDelete();
  $t->timestamp('checked_in_at')->nullable();
  $t->index(['store_id', 'checkin_code'], 'buys_store_checkin_code_idx'); // plain lookup index
  ```
  - [ ] **Step 2a (review B1 — REQUIRED):** relax `terms_version` to nullable in this same migration. Plan 2 created it `string` **NOT NULL with no default** (verified in `create_buys`), so a `qr_code`/`remote_check_in` buy — which has no terms at creation (D1) — would 500 on insert. Use `$t->string('terms_version')->nullable()->change();` (Laravel 11 native `change()`, no doctrine/dbal needed). Add a test that creates a `qr_code` buy with `terms_version = null`.
  - [ ] **Step 2b (review I3):** do **NOT** use a Postgres partial-unique index on active `checkin_code` — it isn't expressible via Laravel's schema builder and raw driver SQL breaks on the SQLite test DB. Uniqueness among a store's *active* codes is enforced at the app layer in `Buy::generateCheckinCode` (Step 6), backed only by the plain index above.
- [ ] **Step 3:** Migration `...000002_create_store_checkin_links_table` — `id` (HasUuid), `store_id` FK, `token` string(64) unique, `is_active` bool default true, `rotated_at`/`revoked_at` nullable, timestamps.
- [ ] **Step 4:** Migration `...000003_add_checkin_fields_to_store_settings_table` — `checkin_enabled` bool default false, `checkin_paused` bool default false, `checkin_status_visibility` string default `'confirmation_only'`, `checkin_ttl_minutes` unsignedInteger default e.g. 1440, `checkin_pii_retention_days` unsignedInteger default e.g. 30.
- [ ] **Step 5:** Migration `...000004_create_pos_devices_table` — mirror `kiosk_devices` (`id`, `store_id`, `name`, `token_hash` unique, `last_seen_at`, `revoked_at`, timestamps).
- [ ] **Step 6:** Models — `StoreCheckinLink` (HasUuid, `store()` relation, `scopeActive`), `PosDevice` (HasUuid, `store()`), factories. `Buy`: add new columns to `$fillable`/`$casts` (`checked_in_at` datetime); add a `Buy::generateCheckinCode(string $storeId): string` helper (3 alpha + 4 digit, ambiguous chars `0/O/1/I/L` removed, retried on active-collision) and `status_token` = `Str::random(43)` set on create for `qr_code` source. `StoreSettings`: add new fields to `$fillable`/`$casts`.
- [ ] **Step 7:** Green on Postgres + SQLite. Pint. `git add` migrations, models, factories, test.

## Task 2 — Config + seller-facing settings exposure

**Files:** `config/checkin.php`, `config/services.php`, `UpdateStoreSettingsRequest`, `StoreSettingsResource`.

- [ ] **Step 1 (test):** `tests/Feature/Stores/StoreSettingsCheckinFieldsTest.php` — seller PATCH updates `checkin_enabled`/`checkin_paused`/`checkin_status_visibility`/`checkin_ttl_minutes`; validation ranges (`checkin_status_visibility` in `{confirmation_only,live}`, `checkin_ttl_minutes` 5–43200, retention 1–365); `StoreSettingsResource` exposes them.
- [ ] **Step 2:** `config/checkin.php` — defaults for ttl, retention, turnstile keys (`env('TURNSTILE_SECRET')`), rate-limit numbers. `config/services.php` — `twilio` (sid/token/from) + `turnstile` block.
- [ ] **Step 3:** Extend `UpdateStoreSettingsRequest` rules + `StoreSettingsResource`. Green. Pint. Commit.

## Task 3 — Refactor `BuyIntakeService` off `KioskDevice` (shared create + extracted grant/convert)

This is the linchpin. Keep kiosk behavior identical; enable the web lane + POS convert.

**Files (modify):** `BuyIntakeService.php`, `Kiosk/Controllers/BuyController.php`. **Tests:** existing kiosk `BuyIntakeTest` must stay green; add `tests/Unit/Kiosk/BuyIntakeServiceRefactorTest.php`.

- [ ] **Step 1 (test):** Add tests asserting (a) kiosk path unchanged (queued, signature attached, loyalty granted); (b) a `qr_code`/`remote_check_in` create grants NO loyalty (`points_earned === 0`, no `loyalty_transactions` row) and requires NO signature; (c) idempotency/fingerprint/409 behavior preserved for both sources. Run on **Postgres** (the resolver savepoint path matters here).
- [ ] **Step 2:** Change signature to:
  ```php
  public function submit(
      string $storeId, BuySource $source, BuyStatus $initialStatus,
      BuyDto $dto, bool $grantLoyalty, bool $requireSignature,
  ): array // ['buy' => Buy, 'status' => int]
  ```
  Replace `$device->store_id` with `$storeId`, `BuySource::Kiosk`→`$source`, `BuyStatus::Queued`→`$initialStatus`. Gate the loyalty block (Step 5–7) on `$grantLoyalty`; gate the post-commit `attachSignature` on `$requireSignature` (skip cleanly when the DTO carries no signature).
  - [ ] **Step 2b (review B2 — REQUIRED):** make `BuyDto` tolerate an absent signature/dl/terms. Plan 2 types `signaturePngBase64: string` (non-null) and `BuyIntakeService` unconditionally calls `attachSignature($dto->signaturePngBase64)`. Change `signaturePngBase64`, `dlNumber`, `termsVersion` to nullable (default `null`/`''`), update `fromArray`, and have `attachSignature` no-op on empty input. The `qr_code` create path builds a DTO with none of these. Add a test constructing a signature-less DTO.
- [ ] **Step 3:** Extract the loyalty computation+write (current Step 5 + Step 7) into `private grantLoyalty(User $user, Buy $buy, StoreSettings $settings): void` and a public `convert(Buy $buy, ?string $dlNumber, string $termsVersion, ?string $signaturePngBase64): Buy` that: asserts `status === remote_check_in` (else 409), transitions to `queued`, attaches signature, sets `dl_number`/`terms_version`, and calls `grantLoyalty` under a row-locked transaction (reuse the `User::lockForUpdate` one-join guard). This is the POS convert path (Task 9).
  - **Loyalty timing (review I4 — resolved):** grant happens **here, at `convert` (`remote_check_in → queued`)**, per spec D3 ("granted by the POS conversion"). This means a customer who later resolves to `no_buy`/`declined` **keeps** the loyalty join — they joined the program by showing up, consistent with the kiosk granting at `queued`. Do NOT defer the grant to the terminal `complete` step. Document this in the module README so it isn't "fixed" later.
- [ ] **Step 4:** Update kiosk `BuyController::store` to call `submit($device->store_id, BuySource::Kiosk, BuyStatus::Queued, $dto, grantLoyalty: true, requireSignature: true)`. Green (Postgres). Pint. Commit.

## Task 4 — `ResolveCheckinStore` + `EnsureCheckinStoreAcceptable` middleware + Request macros

**Files:** the two middleware, `CheckinServiceProvider`, `bootstrap/app.php` aliases. **Tests:** `tests/Feature/Checkin/CheckinStoreResolutionTest.php`.

- [ ] **Step 1 (test):** unknown/revoked/inactive token → **410**; valid token on a suspended/soft-deleted store, or a store with `checkin_enabled=false` / `checkin_paused=true` → **423** with friendly message; happy token binds `$request->checkinStore()` + `$request->checkinLink()`.
- [ ] **Step 2:** `ResolveCheckinStore` — look up active `StoreCheckinLink` by route `{token}`; 410 if none/revoked; set `checkin_store`/`checkin_link` on `$request->attributes`. `EnsureCheckinStoreAcceptable` — 423 unless store is live AND `checkin_enabled` AND not `checkin_paused` (reuse the `Store::withTrashed` + suspended check from `EnsureKioskStoreActive`).
- [ ] **Step 3:** `CheckinServiceProvider::boot` registers `Request::macro('checkinStore'|'checkinLink')`. Register aliases `checkin.store`, `checkin.acceptable` in `bootstrap/app.php`. Green. Pint. Commit.

## Task 5 — Turnstile verification

**Files:** `TurnstileVerifier`, `VerifyTurnstile` middleware, alias. **Tests:** `tests/Unit/Checkin/TurnstileVerifierTest.php`, `tests/Feature/Checkin/TurnstileMiddlewareTest.php`.

- [ ] **Step 1 (test):** with `Http::fake()`, a valid token → pass; invalid/absent `turnstile_token` → **403**; verifier posts `secret` + `response` + client IP to `https://challenges.cloudflare.com/turnstile/v0/siteverify` and reads `success`. In `local`/`testing` env with no secret configured, verifier **bypasses** (so other feature tests don't need a live token) — assert this explicitly.
- [ ] **Step 2:** Implement; `VerifyTurnstile` reads `turnstile_token` from the body. Green. Pint. Commit.

## Task 6 — Rate limiters (IP / phone-HMAC / token / global)

**Files:** `CheckinServiceProvider` (define `RateLimiter::for('checkin-create'|'checkin-status'|'checkin-branding')`). **Tests:** `tests/Feature/Checkin/CheckinRateLimitTest.php`.

- [ ] **Step 1 (test):** structural — assert each limiter returns the expected composite limits (e.g. `checkin-create` = per-IP N/min + per-phone-HMAC M/min + per-token + a daily ceiling). Phone keyed by `hash_hmac('sha256', $e164, config('app.key'))`; closure must not 500 on an invalid/absent phone (wrap normalization in try/catch, mirror kiosk-lookup).
- [ ] **Step 2:** Implement. Green. Pint. Commit.

## Task 7 — Public endpoints: branding + submit + status

**Files:** the three controllers, `SubmitCheckinRequest`, the three resources, `Checkin/routes.php`, `routes/api.php`. **Tests:** `tests/Feature/Checkin/{BrandingTest,SubmitCheckinTest,CheckinStatusTest}.php`.

- [ ] **Step 1 (branding test + impl):** `GET /v1/checkin/{token}` → store name/logo/hours/`terms_version`/`status_visibility`. 410/423 paths covered by Task 4. Cacheable (short `Cache-Control`).
- [ ] **Step 2 (submit test, Postgres):** `POST /v1/checkin/{token}/requests`. `SubmitCheckinRequest` validates `idempotency_key` (uuid; header==body, reuse kiosk rule), `phone` (`ValidPhone`), `first_name`/`last_name`, `container_count` (1–20), `container_description` (nullable, max 255), `opt_loyalty`/`opt_txn`/`opt_promo` (bool), `turnstile_token`. Controller maps to a `BuyDto` (no signature/dl/terms) and calls `BuyIntakeService::submit($request->checkinStore()->id, BuySource::QrCode, BuyStatus::RemoteCheckIn, $dto, grantLoyalty: false, requireSignature: false)`. On create, set `checked_in_at`, generate `checkin_code` + `status_token`, set `duplicate_of_buy_id` if an open `remote_check_in` exists for `(store_id, phone)`. Assertions: **201** returns `{checkin_code, status_token, status_url}`; replay **200**; key-reuse-different-body **409**; duplicate open check-in still **201** but `duplicate_of_buy_id` set; loyalty NOT granted; **no** `dl_number`/signature persisted.
- [ ] **Step 3 (optional-login test — review I2):** the route is NOT behind `auth:sanctum`. Resolve an optional bearer **explicitly** in the controller via `auth('sanctum')->user()` (or `$request->user('sanctum')`). If present+valid → attach the buy to that `User`, skip phone match-or-create. If **absent OR invalid → ignore it** and run `KioskCustomerResolver` (a public lane must never 401 on a bad token). Test all three: valid bearer attaches; no bearer resolves by phone; garbage bearer is ignored (not 401).
- [ ] **Step 4 (status test — review I5):** `GET /v1/checkin/status/{status_token}` → `{status, status_label, queue_position?, estimated_wait_minutes?, container_count}`. Reuse `KioskQueueService::positionFor(Buy $buy)` — verified signature returns `array{position:int, etaMinutes:int}` and **always** computes eta. The controller maps `etaMinutes` → `estimated_wait_minutes` and **only includes `queue_position`/`estimated_wait_minutes` when the store's `checkin_status_visibility === 'live'`**; `confirmation_only` returns status alone. Unknown token → 404. `throttle:checkin-status`.
- [ ] **Step 5:** Wire `Checkin/routes.php` under `['checkin.store','checkin.acceptable']`; submit additionally under `['idempotency','throttle:checkin-create','verify.turnstile']`; status under `throttle:checkin-status` (NOT behind the per-store middleware — keyed by `status_token`). `require` from `routes/api.php`. Green (Postgres). Pint. Commit.
  - **Idempotency note (review N1):** `IdempotencyMiddleware` keys its 24h cache on `hash(method|path|Authorization|body)` (verified). On the anonymous lane `Authorization` is empty (a constant component) — the **real** dedupe is the durable `unique(store_id, idempotency_key)` on `buys`, which `BuyIntakeService` already enforces with a 200-replay / 409-mismatch. Edge case: an anonymous submit then a logged-in retry with the same key → cache *miss* (different fingerprint), but the durable unique still returns 200/409 correctly. Don't rely on the cache for cross-auth replay; rely on the row.

## Task 8 — POS device auth + provisioning commands

**Files:** `AuthenticatePosDevice`, `ProvisionPosDevice`, `RevokePosDevice`, alias. **Tests:** `tests/Feature/Pos/PosDeviceAuthTest.php`, `tests/Feature/Pos/PosProvisioningTest.php`.

- [ ] **Step 1 (test, Postgres):** middleware mirrors `AuthenticateKioskDevice` (bearer → `sha256` → unrevoked `PosDevice`; 401 otherwise; stamps `last_seen_at`, sets `pos_device`/`pos_store`). Provisioning: `pos:provision-device {store}` prints the plaintext token once; **`Str::isUuid` guard before the lookup** (Plan-2 lesson — non-UUID → "Store not found", no 22P02); `pos:revoke-device {device}` same guard.
- [ ] **Step 2 (incl. review N2):** Implement (copy kiosk command structure incl. the UUID guards from the start). Register `pos.device` alias + a **POS-specific `pos.store.active`** guard. Note: this must NOT reuse `EnsureCheckinStoreAcceptable` verbatim — POS staff must still operate when **public check-ins are paused** (`checkin_paused=true`); the POS guard only 423s on a suspended/soft-deleted store. Green (Postgres). Pint. Commit.

## Task 9 — POS endpoints: list / lookup / labels / convert / complete

**Files:** `PosCheckinController`, `PosBuyController`, `ConvertCheckinRequest`, `CompleteBuyRequest`, `PosCheckinResource`, `Pos/routes.php`. **Tests:** `tests/Feature/Pos/{ListCheckinsTest,LookupCheckinTest,ConvertCheckinTest,CompleteBuyTest}.php`.

- [ ] **Step 1 (list/lookup):** `GET /v1/pos/checkins` → device-store's open `remote_check_in` buys (code, name, container_count/desc, `duplicate_of_buy_id`, `checked_in_at`), paginated. `GET /v1/pos/checkins/lookup?phone=|code=` → resolve one (404 if none). Store-scoped — a device can never read another store's buys.
- [ ] **Step 2 (labels):** `POST /v1/pos/checkins/{buy}/labels-printed` → advisory flag/timestamp; idempotent.
- [ ] **Step 3 (convert, Postgres):** `POST /v1/pos/checkins/{buy}/convert` with `ConvertCheckinRequest` (`dl_number?`, `terms_version`, `signature_png_base64` — reuse kiosk PNG validation). Calls `BuyIntakeService::convert(...)` (Task 3): `remote_check_in → queued`, attach signature on `local` disk, **grant loyalty now** (one-join guarded). 409 if not in `remote_check_in`. Assert loyalty granted exactly once even on a double convert.
- [ ] **Step 4 (complete):** `POST /v1/pos/buys/{buy}/complete` with `CompleteBuyRequest` (`outcome` in `{accepted,no_buy,declined}`). Sets the terminal status; **fires `BuyCompletedNotification` (Task 10)** gated on `opt_txn`. Idempotent on an already-terminal buy (no duplicate SMS — guard on a `completed_notified_at` or state transition).
- [ ] **Step 5:** Wire `Pos/routes.php` under `['pos.device','pos.store.active']`. `require` from `routes/api.php`. Green (Postgres). Pint. Commit.

## Task 10 — Twilio completion SMS

**Files:** `composer require laravel-notification-channels/twilio`; `BuyCompletedNotification`; `User` already `Notifiable`. **Tests:** `tests/Feature/Pos/CompleteBuySmsTest.php`.

- [ ] **Step 1 (test):** with `Notification::fake()`, completing a buy whose customer has `opt_txn=true` queues `BuyCompletedNotification` via the Twilio channel; `opt_txn=false` → nothing sent. Message body contains the store name + the signed `status_url`, contains **no** dollar amount, and includes STOP guidance. `routesNotificationForTwilio()` returns the customer phone.
- [ ] **Step 2:** Implement `toTwilio()`; route the phone via the notifiable. Config from `config/services.php`. Green. Pint. Commit. (Twilio creds are env-only; the channel is faked in tests.)

## Task 11 — Scheduled jobs: auto-void + PII purge

**Files:** `AutoVoidStaleCheckins`, `PurgeCheckinPii`, `routes/console.php`. **Tests:** `tests/Feature/Checkin/{AutoVoidStaleCheckinsTest,PurgeCheckinPiiTest}.php`.

- [ ] **Step 1 (test):** auto-void transitions `remote_check_in` buys older than the store's `checkin_ttl_minutes` to `voided` (per-store TTL; freeze the clock — Plan-2 lesson). Purge anonymizes/clears `first_name`/`last_name`/`phone`(on the buy snapshot)/`container_description` on voided or abandoned check-ins past `checkin_pii_retention_days`; does NOT touch converted/active buys.
- [ ] **Step 2 (review I1):** Implement commands; register them in **`bootstrap/app.php`'s `->withSchedule(...)` closure** (NOT `routes/console.php` — verified: this app schedules `payouts:run-cycle`/`payouts:retry-failed`/`orders:reconcile-money` there). e.g. `$schedule->command('checkin:auto-void')->hourly()->withoutOverlapping();` and `$schedule->command('checkin:purge-pii')->daily();`. Green. Pint. Commit.

## Task 12 — OpenAPI contract + final sweep

**Files:** `api/contracts/openapi.yaml`. (Note: `alqove-web` has its **own** `contracts/openapi.yaml` that generates `@alqove/types`; the web plan syncs it.)

- [ ] **Step 1:** Add paths: `GET /v1/checkin/{token}`, `POST /v1/checkin/{token}/requests`, `GET /v1/checkin/status/{statusToken}`, `GET /v1/pos/checkins`, `GET /v1/pos/checkins/lookup`, `POST /v1/pos/checkins/{buy}/labels-printed`, `POST /v1/pos/checkins/{buy}/convert`, `POST /v1/pos/buys/{buy}/complete` + component schemas (`CheckinBranding`, `CheckinAccepted`, `CheckinStatus`, `PosCheckin`). Document the 200/201/409/410/423 responses.
- [ ] **Step 2:** Full suite on **Postgres** + Pint + phpstan (no new errors). Update `api/CLAUDE.md` module list (add `Checkin`, `Pos`). Commit. Open PR `feature/remote-checkin-api` → `main`; the design-spec decisions (D1–D18) are the review baseline.

---

## Open questions for the plan-review pass (carry from spec)
1. `pos_devices` standalone vs unifying with `kiosk_devices` via a `type` column. Standalone here (lower risk); flag if reviewers prefer unification.
2. `checkin_code` exact format/length + recycle policy (proposed 3-alpha-4-digit, unique among a store's non-terminal buys).
3. Convert request: share kiosk `BuyRequest`'s signature/terms validation vs a POS-specific request (leaning: share the rules).
4. Store **hours** as first-class data vs free-text for v1 branding.
5. 10DLC / Twilio sender registration ownership.
