# Pos module

The staff-facing lane for the remote check-in flow. Authenticated as a
provisioned **store device** (`pos.device` + `pos.store.active`), mirroring the
kiosk-device pattern. Consumed by `alqove-pos` (the desktop staff app, built
elsewhere). Implements the minimal POS lifecycle slice (D14).

## Endpoints (`/v1/pos/*`)

| Method | Path | Notes |
|---|---|---|
| GET | `/pos/queue` | Store's live work queue: **all non-terminal buys** (any source/stage), ordered by `created_at`. `?updated_after=<iso8601>` returns only the changed-since delta for incremental polling (cursor is a filter, not the sort key — advance the watermark to the max `updated_at` seen). |
| GET | `/pos/checkins` | Store's open `remote_check_in` buys, paginated. |
| GET | `/pos/checkins/lookup?phone=\|code=` | Resolve one open check-in (404 if none). Store-scoped. |
| POST | `/pos/checkins/{buy}/labels-printed` | Advisory `labels_printed_at` stamp; idempotent. |
| POST | `/pos/checkins/{buy}/convert` | `remote_check_in → queued`; captures DL + terms + signature in person and **grants loyalty** (D3, one-join guarded). 409 if not awaiting conversion. |
| POST | `/pos/buys/{buy}/complete` | Set a terminal status (`accepted`/`no_buy`/`declined`); fires the Twilio completion SMS (gated on `opt_txn`). Idempotent on an already-terminal buy. |
| GET | `/pos/buys/{buy}/items` | List the buy's intake items (photos w/ slot+order, quote, outcome, conversion state), ordered by `sort_order`. |
| PUT | `/pos/buys/{buy}/items/{item}` | **Idempotent upsert** on the client UUID (I1): materialize (`201`) or update (`200`). Quote-time metadata; setting an offer flips `pending → quoted`. 409 once finalized; non-UUID `{item}` 404s. |
| POST | `/pos/buys/{buy}/items/{item}/resolve` | Per-item outcome: `accepted`, or `declined` + `disposition` (`returned_to_customer`\|`recycled`). 409 if already terminal or buy finalized. |
| DELETE | `/pos/buys/{buy}/items/{item}` | Soft-delete a mis-sorted item before finalize. 409 once finalized. |
| POST | `/pos/buys/{buy}/finalize` | Terminal step: roll-up → buy status, record settlement totals, fire SMS once, dispatch a draft-listing job per accepted item. Idempotent. |

## Intake items domain

A buy is sorted into individual **intake items** (`BuyItem`, shared model in
`app/Models/`). The `item_id` is **client-generated** (UUID PK) and the API
**materializes** the row on first reference — either the first capture photo
upload (Capture module) or an explicit POS upsert (I1). Each item carries its
photos (the moved-in capture blobs), a cash + store-credit quote with listing
metadata (title, description, category, brand, size, condition, suggested
`listing_price`), and a per-item outcome (`pending → quoted → accepted|declined`).

- **Quoting** (`PUT …/items/{item}`) is idempotent on the UUID; setting either
  offer moves `pending → quoted`. `category_id` is optional here — it is only
  required at finalize (I15).
- **Resolution** (`POST …/items/{item}/resolve`) sets the per-item outcome. A
  `declined` item must record a `disposition` (the physical outcome).
- A non-UUID `{item}` 404s **before** any query so it never hits the uuid column
  (Postgres 22P02 guard); a `{buy}`/`{item}` in another store also 404s.

## Finalize (roll-up + settlement + conversion)

`POST …/finalize` (`PosFinalizeController` → `BuyFinalizer`) is the terminal
step (I7/I10):

1. **Validate** every item is resolved, and every **accepted** item carries the
   fields the marketplace `items` table requires to convert — `title`,
   `condition`, `category_id`, `listing_price` (B2/I15) — else `422` naming the
   offending item ids.
2. **Roll-up rule (I7):** `customer_rejected=true` → `declined`; else ≥1 accepted
   → `accepted`; else (items exist, none accepted) → `no_buy`.
3. **Record settlement totals (I8):** `cash_total` / `store_credit_total` =
   server-side sum of accepted offers (recorded, not executed). Sets
   `finalized_at`.
4. **Fire the completion SMS once** via the shared `BuyCompletionNotifier`
   (opt_txn + `completed_notified_at` gate, reused by `complete()` — I4).
5. **Convert each accepted item** into a draft marketplace `Item` (see below).

Idempotent: a second finalize on an already-finalized buy is a no-op (no
re-transition, no second SMS, no duplicate drafts) and never 422s. It
**supersedes** `complete` for item-bearing buys; `complete` stays for the legacy
no-item path (both fire the same notification, guarded by `completed_notified_at`).

### Queued listing conversion (I5/I12)

Each accepted item is converted by a **queued** `ConvertAcceptedBuyItem` job
(dispatched per item at finalize, outside the state transaction) wrapping
`IntakeListingConverter`. The converter routes through `ItemService::create($store,
$store->owner, [...])` (forces `ItemStatus::Draft`, fires no events/Scout/jobs —
I3) and **copies** each `photos` media into the `Item` `images` collection
(`Media::copy`, regenerating the four `Item` conversions). It sets
`buy_item.converted_item_id` (idempotency guard — a re-run skips an already-converted
item).

Conversion runs **async**, so finalize returns before the drafts exist — the buy
is already terminal and the SMS already sent, so a slow WebP encode never blocks
settlement. A job failure stamps `conversion_failed_at` on the item and is logged;
`php artisan intake:retry-conversions` re-dispatches only the flagged items and
clears the marker on success.

## Realtime item nudges (Ably)

`BuyItemBroadcaster` (a `BuyItem` observer, registered in `PosServiceProvider`)
publishes a best-effort `item.upserted` nudge to the **buy-scoped** channel
`store:{storeId}:buy:{buyId}:items` on item **create** and on a **meaningful
status change** (`pending → quoted` on offer, `→ accepted/declined` on resolve —
incidental field touches like `sort_order` don't nudge). This keeps the
(possibly multiple) POS terminals on one buy in sync without polling.

The POS token (`POST /v1/realtime/pos-token`) grants `subscribe` on a
**one-segment wildcard** `store:{storeId}:buy:*:items` (`CaptureChannels::
buyItemsWildcard` / `posCapability`), so one token covers every buy a terminal
touches (`*` matches the buy-id segment; Ably passes it through verbatim — N3).

Like the queue board, this is a **nudge, not the source of truth**: a publish
failure is logged and swallowed (never fails the item write). Durability is the
`GET …/items` pull-backfill. Event payload: `{ protocolVersion, action
(created|updated), id, buyId, status, isTerminal, disposition, sortOrder,
updatedAt, ts }`.

## Realtime queue (Ably)

`BuyQueueBroadcaster` (a `Buy` observer) publishes a low-latency `buy.upserted`
nudge to the **store-wide** Ably channel `store:{storeId}:queue` on buy
**create** and **status change** (including the move to a terminal state, which
the POS reads via `isTerminal` to remove the row). The POS subscribes with the
token from `POST /v1/realtime/pos-token` — `CaptureChannels::posCapability`
grants `subscribe` on the queue channel alongside the per-terminal capture
channels.

This is a **nudge, not the source of truth**: a publish failure is logged and
swallowed (never fails the buy write). Durability is guaranteed by the
`GET /v1/pos/queue` pull-backfill — the POS pulls on connect/reconnect and may
re-pull on any nudge. (Symmetry with capture's `photo.added` ↔ `GET /intake/pending`.)

Event payload: `{ protocolVersion, action(created|updated), id, source, status,
statusLabel, isTerminal, checkinCode, firstName, lastName, containerCount,
updatedAt, ts }`.

## Store isolation

Every `{buy}` action verifies `buy.store_id === posStore.id` and 404s
otherwise — a device can never read or mutate another store's buys.

## Notes

- `pos.store.active` deliberately does NOT 423 when public check-ins are paused
  or disabled — staff must keep working through a queue while the public lane is
  closed. It only locks on a suspended or soft-deleted store (N2).
- Provision/revoke devices via `pos:provision-device {store}` /
  `pos:revoke-device {device}` (both UUID-guarded).
