# Intake Items Domain (Capture Phase 2) — Design

**Status:** Draft · 2026-06-24
**Repos:** `alqove-api` (this domain + POS APIs) · `alqove-inflow` (RN capture app — uploads photos; *consumes* the API) · `alqove-pos` (staff desktop — quoting/resolution UI; *consumes* the API; not built here)
**Predecessors:** Kiosk Plans 1–2 (`buys`, `BuyStatus`/`BuySource`, idempotency) · Remote Check-In (merged: `/v1/checkin/*`, `/v1/pos/*` minimal slice, queue pull + Ably push) · Capture Phase 1 (`feat/capture-phase1`: pairing, Ably tokens, the photo upload + POS reconciliation loop — `CaptureUpload` blobs keyed by **plain-string** `buy_id`/`item_id`).
**Successors:** Money settlement (real cash payout + store-credit issuance); seller listing-completion UX; appointment scheduling.

---

## Purpose

Capture Phase 1 built a resilient photo **transport** — but a captured photo is an orphan: `CaptureUpload.item_id`/`buy_id` are plain strings pointing at an entity the API does not model. There is **no intake-item concept** in the API today (`Buy` has no items; the only `Item` is a marketplace listing).

This milestone introduces the **intake-item domain** — the thing being graded, quoted, and either bought or handed back during an in-store buy — and wires the whole flow end to end:

1. A customer's containers (from check-in) are sorted into individual **items**. Each item's `item_id` is generated client-side; the API **materializes** a `BuyItem` on first reference (first photo upload or an explicit POS upsert).
2. Captured photos **attach** to their `BuyItem` (slots → a single ordered media collection). The ephemeral capture blob moves into the item's media on the POS `attached` ack.
3. Staff **quote** each item in `alqove-pos`: capture listing-grade metadata (title, category, brand, size, condition, suggested listing price) plus a **cash + store-credit offer**.
4. Staff **resolve** each item: `accepted`, or `declined` with a disposition (`returned_to_customer` | `recycled`).
5. Staff **finalize** the buy. A roll-up rule sets the buy's terminal state; each accepted item is converted into a **draft marketplace `Item`** (reusing its photos + quote-time metadata), and the agreed cash/store-credit totals are recorded on the buy. The existing completion SMS fires.

The legally/financially sensitive *settlement* (actually paying cash, issuing store credit) is **recorded, not executed** here — that's the next milestone.

## Why the API owns intake items (Option A)

Capture Phase 1 is deliberately "POS-authoritative, API-is-coarse-transport." We extend that — the POS still adjudicates **decisions** (quotes, outcomes) — but the **item, its photos, its quote, and its outcome become first-class API state**, because:

- Photos must become durable marketplace listing images (the `Item` `images` MediaLibrary), which only the API can own.
- Quoting + per-item outcomes + buy roll-up are domain truth that must survive a POS reboot, be auditable, and drive the customer-facing buy lifecycle (`BuyStatus` `sorting → sorted → in_progress → quoted` — defined but unused, this milestone gives them meaning).
- Intake→listing conversion needs both sides (BuyItem + Item) in one transactional store.

## User model

- **Capture device (phone, `alqove-inflow`).** Authenticated `capture.device`. Uploads photos against a client `item_id`. **Never** sets price/grade/outcome — write surface is photos only.
- **POS device (`alqove-pos`).** Authenticated `pos.device` + `pos.store.active`. Owns quoting, resolution, finalize. Store-scoped — can never touch another store's buys/items.
- **Seller (store owner).** Inherits the auto-created **draft** listings; reviews/edits/publishes them later in the marketplace seller UI (out of scope here).

## Architecture & project boundary

| Concern | Project | Notes |
|---|---|---|
| `buy_items` domain, quoting, resolution, finalize, conversion | `alqove-api` | This spec |
| Photo upload (phone) | `alqove-inflow` → `alqove-api` | Phase-1 endpoints; upload now materializes a `BuyItem` |
| Quoting / resolution / finalize UI | `alqove-pos` | Consumes the new `/v1/pos/*` surface; not built here |
| Draft listing review + publish | marketplace web (seller) | Out of scope; reuses existing `Item` + `ItemStatus::Draft` |

---

## Decisions

| # | Decision | Choice |
|---|---|---|
| I1 | Item identity | **Client-generated UUID PK + lazy upsert.** `item_id` (UUID) is the PK; the API materializes a `BuyItem` on first reference (first photo upload OR an explicit POS upsert). Matches capture's `upload_id` idempotency model; no round-trip. |
| I2 | Outcome granularity | **Per-item outcome + buy roll-up.** Each `BuyItem` carries its own outcome; the `Buy`'s terminal state is a computed roll-up. Represents partial buys (store takes some, hands back others). |
| I3 | Offer pricing | **Cash + store-credit split.** Each item carries `cash_offer` and `store_credit_offer` (cents, nullable until quoted). Buy totals roll up both. |
| I4 | Listing conversion | **Auto-create a draft `Item` on accept**, at buy-finalize. |
| I5 | Photo organization | **One `photos` MediaLibrary collection** on `BuyItem`; each photo tagged with its `slot` (Front/Back/Tag/Detail/Extra) as a custom property + explicit order. On `attached` ack the capture blob is **moved** in; on conversion the set is **copied** into the `Item` `images` collection. |
| I6 | Draft richness | **Rich draft at quote time.** The POS captures full listing metadata (title, category, suggested listing price, brand, size, condition) during quoting, so the draft is near-publishable. |
| I7 | Buy finalization | **Explicit POS `finalize` + roll-up rule:** ≥1 accepted → `accepted`; items exist but none accepted → `no_buy`; customer rejects the whole offer → `declined`. Staff-controlled, predictable. |
| I8 | Money settlement | **Record amounts only.** Store the agreed cash + store-credit totals on the buy/items. Real payout + credit issuance is a later milestone. |
| I9 | Non-accepted items | **`accepted` / `declined` + `disposition`** (`returned_to_customer` \| `recycled`). Captures the decision *and* the physical outcome. |
| I10 | Listing timing | **At buy-finalize, for all accepted items**, transactionally + idempotently. A reversed mid-quote accept never spawns an orphan draft. |
| I11 | Draft listing status | **`ItemStatus::Draft`** (not searchable — `shouldBeSearchable` requires `Active`). Seller reviews/edits and publishes later. Seller = `store.owner_user_id`. |
| I12 | Conversion-failure atomicity | **Finalize anyway, flag for retry.** The buy reaches its terminal state + records totals + fires SMS even if an accepted item's listing conversion fails; the failed item gets a `conversion_failed_at` marker and is re-runnable via an `intake:retry-conversions` command. One bad image can never block settlement. |
| I13 | Per-item realtime | **In-scope bake-in.** Best-effort `item.upserted` nudge to a buy-scoped Ably channel (`store:{storeId}:buy:{buyId}:items`) on item create/quote/resolve; durability via `GET …/items`. Same nudge-vs-backfill pattern as the queue push. |
| I14 | listing_price timing | **Captured at quote time**, alongside the cash/credit offer (counter-side margin visibility); seeds `Item.price`. |
| I15 | Category gate | **Required only at convert/finalize**, not to quote/accept (422 at finalize naming any accepted item missing a `category_id`). Keeps quoting fast. |

---

## Data model

### New: `buy_items`
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | **client-generated** `item_id` (HasUuid trait *not* used for gen — accept caller's UUID; validate `Str::isUuid`) |
| `buy_id` | uuid FK → `buys` | `cascadeOnDelete`; indexed |
| `store_id` | uuid FK → `stores` | denormalized for scoping (mirrors `CaptureUpload`) |
| `status` | string enum `BuyItemStatus` | `pending` → `quoted` → `accepted`/`declined` (terminal) |
| `disposition` | string enum `BuyItemDisposition` nullable | set only when `declined`: `returned_to_customer` \| `recycled` |
| `title` | string nullable | quote-time; seeds `Item.title` |
| `description` | text nullable | seeds `Item.description` |
| `category_id` | FK → `categories` nullable | required to *convert*; validated at finalize |
| `brand` / `size` | string nullable | seed `Item` |
| `condition` | string enum `ItemCondition` nullable | reuse the marketplace enum; seeds `Item.condition` |
| `listing_price` | unsignedInteger nullable | suggested marketplace price (cents); seeds `Item.price` |
| `cash_offer` | unsignedInteger nullable | what the store pays the customer (cents) |
| `store_credit_offer` | unsignedInteger nullable | store-credit alternative (cents) |
| `converted_item_id` | uuid FK → `items` nullable | set on conversion; `nullOnDelete`; idempotency guard |
| `conversion_failed_at` | timestamp nullable | set when convert fails at finalize (I12); cleared on successful retry |
| `quoted_at` / `resolved_at` | timestamp nullable | lifecycle stamps |
| `sort_order` | unsignedInteger default 0 | item ordering within the buy |
| timestamps + `softDeletes` | | |

- **Media:** `BuyItem implements HasMedia`, collection `photos` (accepts `image/jpeg`), photos ordered + `slot` stored as a custom property. (Conversions are generated on the `Item` at convert time, not here — keep intake storage cheap.)

### `buys` additions (settlement roll-up + finalize)
| Column | Type | Notes |
|---|---|---|
| `cash_total` | unsignedInteger nullable | recorded sum of accepted `cash_offer` (I8) |
| `store_credit_total` | unsignedInteger nullable | recorded sum of accepted `store_credit_offer` |
| `finalized_at` | timestamp nullable | set by `finalize`; idempotency guard |

### `CaptureUpload` (unchanged shape, new behavior)
`item_id`/`buy_id` stay plain strings (transport contract is frozen). The **domain link lives in `buy_items`**, not on the upload row. Phase-1 endpoints are untouched except: `store()` upserts a placeholder `BuyItem`; `ack(attached)` moves the blob into that item's `photos`.

### Enums (new, `app/Support/Enums/`)
- `BuyItemStatus`: `Pending`, `Quoted`, `Accepted`, `Declined` (+ `isTerminal()`).
- `BuyItemDisposition`: `ReturnedToCustomer`, `Recycled`.

---

## API surface

All under `pos.device` + `pos.store.active`, store-scoped (404 cross-store), except the photo upload which stays on `capture.device`.

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/pos/buys/{buy}/items` | List the buy's items (photos w/ slot+order, quote, outcome, conversion state). |
| `PUT` | `/v1/pos/buys/{buy}/items/{item}` | **Idempotent upsert** (I1): materialize-or-update. Body = quote-time metadata (title, description, category_id, brand, size, condition, listing_price, cash_offer, store_credit_offer). Setting an offer moves `pending → quoted`. |
| `POST` | `/v1/pos/buys/{buy}/items/{item}/resolve` | Set outcome: `accepted`, or `declined` + `disposition`. Sets `resolved_at`. 409 if already terminal. |
| `DELETE` | `/v1/pos/buys/{buy}/items/{item}` | Remove a mis-sorted item (soft delete) before finalize. |
| `POST` | `/v1/pos/buys/{buy}/finalize` | **Terminal step (I7/I10):** require every item resolved; apply roll-up → buy terminal state; record `cash_total`/`store_credit_total`; convert each accepted item → draft `Item`; set `finalized_at`; fire `BuyCompletedNotification` (existing, gated on `opt_txn`). Idempotent on an already-finalized buy. |
| `POST` | `/v1/intake/{itemId}/photos` | *(Phase-1, modified)* upload now upserts a placeholder `BuyItem(item_id, buy_id, store_id, status=pending)`. |
| `POST` | `/v1/intake/uploads/{uploadId}/ack` | *(Phase-1, modified)* `attached` now **moves the blob** into the item's `photos` collection (slot as property), in addition to the existing status mirror. |

`finalize` **supersedes** the minimal-slice `POST /v1/pos/buys/{buy}/complete` for item-bearing buys (complete stays for the legacy no-item path; both fire the same notification, guarded by `completed_notified_at`).

### Conversion (finalize, per accepted item)
`IntakeListingConverter`: create `Item { store_id, seller_id: store.owner_user_id, status: Draft, title, description, category_id, brand, size, condition, price: listing_price }`; **copy** each `photos` media into the `Item` `images` collection (triggering its thumb/medium/large/og conversions); set `buy_item.converted_item_id`. Skip if already converted (idempotent). Wrapped in the finalize transaction (Postgres savepoint per item so one bad item can't abort the whole finalize — Plan-2 lesson).

### Realtime (bake-in)
On `BuyItem` create/quote/resolve, publish a best-effort `item.upserted` nudge to a **buy-scoped** Ably channel (`store:{storeId}:buy:{buyId}:items`) so multiple POS terminals on one buy stay in sync. Reuses `AblyPublisher`; durability via `GET …/items`. Mirrors the queue-push pattern. (Optional; flag if it should be its own milestone.)

---

## Lifecycle

```
BuyItem:  pending ──(PUT offer)──▶ quoted ──(resolve)──▶ accepted ─┐
                                          └──(resolve)──▶ declined ─┤(+disposition)
                                                                    ▼
Buy:  …queued → (sorting/quoting happen as items move) → finalize ─▶ accepted | no_buy | declined
                                                          │
                                                          ├─ accepted items → draft Item (status=Draft)
                                                          ├─ cash_total / store_credit_total recorded
                                                          └─ BuyCompletedNotification (opt_txn)
```

**Roll-up rule (I7):** `accepted` if ≥1 item accepted; else `no_buy` if items exist but none accepted; `declined` reserved for an explicit "customer rejected the whole offer" finalize flag.

---

## Security & integrity

- **Scoping:** every `{buy}`/`{item}` action asserts `buy.store_id === posStore.id` and `item.buy_id === buy.id` (404 otherwise). Photo upload stays device+store-scoped.
- **Authority split:** capture device = photos only; POS device = quote/resolve/finalize. No customer/web write surface.
- **Idempotency:** item upsert is naturally idempotent on the client UUID PK; `finalize` and conversion guard on `finalized_at` / `converted_item_id`; `Str::isUuid` guard before any `item_id` lookup (Plan-2 lesson — non-UUID must not 22P02 on Postgres).
- **Money:** all amounts `unsignedInteger` cents; no negative offers; totals computed server-side from accepted items, never trusted from the client.
- **Postgres portability:** FKs + enum-string columns + savepoint-wrapped per-item conversion; run the suite on Postgres, not just SQLite (phpunit default).

---

## Edge cases

- Photo uploaded for an `item_id` whose buy is already terminal/finalized → reject (`item_finalized`, existing reason taxonomy).
- `resolve`/`PUT` on a finalized buy → 409.
- `finalize` with an unresolved or un-quoted-but-accepted item → 422 (list the offending items).
- Accepted item missing a `category_id` (required to list) → 422 at finalize, naming the item.
- Conversion partially fails (one item's media copy errors) → savepoint rolls back that item only; finalize completes, the buy reaches its terminal state, and the failed item is stamped `conversion_failed_at` for `intake:retry-conversions` (I12).
- Duplicate `finalize` → no-op (already finalized), no second SMS, no duplicate drafts.
- Item soft-deleted after a photo was attached → its capture blobs are already moved; orphaned media cleaned by the existing media lifecycle.

---

## Resolved during design (Round 4)

All five design opens were settled before drafting the plan:

1. **Conversion failure → finalize anyway + retry** (I12): `conversion_failed_at` marker + `intake:retry-conversions` command; settlement is never blocked by a single failed conversion.
2. **`store_credit` semantics** (I8): persist the agreed `store_credit_total` only — no customer credit ledger this milestone, no balance touched.
3. **Realtime item nudges** (I13): in-scope bake-in on a buy-scoped channel.
4. **listing_price** (I14): captured at quote time alongside the offer.
5. **Category** (I15): required only at convert/finalize, not to quote/accept.

## Open questions for the plan-review (codex) pass

- Confirm the quoting/finalize surface needs no device scope beyond `pos.device` (no per-staff attribution this milestone).
- `BuyItem` must **not** be `Searchable` — intake items aren't marketplace inventory until converted.
- Migration ordering vs the unmerged base: this plan **branches off `feat/capture-phase1`** (not `main`), because it modifies `CaptureUploadController` (upload upsert + ack blob-move).
