# Intake Items Domain (Capture Phase 2) — 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:** Build the intake-item domain defined in `docs/superpowers/specs/2026-06-24-intake-items-domain-design.md` (decisions I1–I15). A new `buy_items` entity (client-UUID PK, lazily materialized) carries a buy's individual graded items: photos (the capture blobs, moved in on `attached` ack), a cash+store-credit quote with rich listing metadata, and a per-item outcome. An explicit POS **finalize** applies a roll-up rule to set the buy's terminal state, records settlement totals (recorded, not executed), and **auto-creates a draft marketplace `Item`** per accepted item (copying photos into the `images` collection). A best-effort `item.upserted` Ably nudge keeps POS terminals in sync.

**Architecture:** Extends the `Pos` module (quoting/resolution/finalize live with the rest of the POS-authoritative surface) and the `Capture` module (the upload/ack path now materializes items + moves blobs). `BuyItem` is a shared model in `app/Models/`. Conversion is a dedicated `IntakeListingConverter` service. Reuses: `BuyCompletedNotification` (terminal SMS), `AblyPublisher` (nudges), `Item` + `ItemStatus::Draft` + the `images` MediaLibrary collection (conversion target), `ItemCondition` (grade), `Store::owner_user_id` (draft seller).

**Tech Stack:** Laravel (Sail), spatie/laravel-medialibrary (photos + conversion copy), PostgreSQL (CI/prod), PHPUnit/Pest, Pint, PHPStan.

**Branch:** `feature/intake-items` **off `feat/capture-phase1`** (NOT `main`) — this plan modifies `CaptureUploadController` and depends on the Phase-1 capture module + the Ably infra, which are unmerged. It therefore lands after / alongside the capture branch.

**Conventions (from `api/CLAUDE.md` + kiosk/checkin precedent):**
- `declare(strict_types=1);`, one class per file, typed properties + returns.
- Modules own Controllers/Requests/Resources/Services/Tests/routes. Shared models in `app/Models/`. Enums in `app/Support/Enums/`. Money = `unsignedInteger` cents. `HasUuid` for generated UUID PKs — but `BuyItem` **accepts a client-supplied UUID** (validate `Str::isUuid`, don't auto-generate).
- Migrations `2026_06_24_*`. Feature tests in `tests/Feature/Pos|Capture/`, unit in `tests/Unit/`. Factories, never raw inserts.
- Commit style: `feat(intake):`, `feat(pos):`, `test(intake):`.

**⚠️ Test-DB portability (LEARNED FROM PLAN 2 + checkin):** `phpunit.xml` defaults to **SQLite `:memory:`** but CI runs **PostgreSQL**. SQLite masks (1) constraint violations not aborting the transaction, (2) uuid columns accepting arbitrary text. Every task touching FKs, raw id lookups, casts, or savepoints MUST be verified on Postgres:
```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>
```

**Pre-existing noise (don't chase):** Typesense indexing tests without an api key; pre-existing larastan level-6 errors. `ABLY_KEY=""` is pinned in phpunit.xml (queue-push work) → fakes are used; assert on `FakeAblyPublisher`.

**Revision history:**
- **v1 (2026-06-24)** — initial 8-task plan from the design spec.
- **v2 (2026-06-24)** — incorporate the codex plan-review (2 BLOCKERs, 5 IMPORTANTs, 3 NITs), all verified against the `feat/capture-phase1` code:
  - **B1 (media ingestion mechanics)** — Two distinct media operations, NOT one. The capture blob is a **raw file** (`CaptureUpload.blob_path` on the `local` disk), not a spatie `Media` object, so the `attached` ack must ingest it with `addMediaFromString(...)` (Task 2), **not** `Media::copy`. Only *after* it lives in `BuyItem.photos` (real Media) can conversion use `Media::copy($item,'images')` (Task 6). Capture disk `local` ≠ media disk `public` → it's a cross-disk read+write. (`Item.images` + `BuyItem.photos` both accept jpeg — OK.)
  - **B2 (Item required fields)** — `items` is NOT NULL on `title`, `category_id`, `condition`, `price`, `description`, `seller_id`, `store_id`. `ItemService::create()` defaults `description=''`/`price=0` but **requires** `title`, `category_id`, `condition`. So `finalize` must validate `title` + `condition` + `category_id` + `listing_price` present on every **accepted** item before convert (not just `category_id`), 422 naming offenders. (Tasks 5–6, refines I15.)
  - **I1 (store() upsert 25P02)** — materialize the `BuyItem` with an **atomic** `upsert`/`insertOrIgnore` (NOT `firstOrCreate` inside the existing `DB::transaction` — that risks a Postgres 25P02 abort on an `item_id` race, and the single-attempt retry loop covers only the CaptureUpload PK race). Do it before/around the upload transaction. (Task 2)
  - **I2 (ack() side-effects)** — spatie media writes are NOT transactional. Ingest the blob + nudge **after** the ack transaction commits, guarded by the now-terminal status so it runs at most once. **Do NOT delete the capture blob** on attach — `copy` it (the POS `blob()` pull + capture's own retention depend on it). (Task 2)
  - **I3 (use `ItemService::create()`)** — route draft creation through it (forces `ItemStatus::Draft`, fires no events/jobs/Scout/slug). Avoid `snapPublish()` (forces Active + `ItemPublished`). (Task 6)
  - **I4 (share the completion-SMS gate)** — extract `PosBuyController::notifyCompletion()` (opt_txn + `completed_notified_at` + phone guards) into a shared service so `finalize` and `complete` reuse it, not duplicate. (Task 5)
  - **I5 (conversion latency → queue it)** — `Item`'s 4 conversions are `->nonQueued()`, running synchronously per photo. A multi-item finalize would block on inline WebP encodes. **Convert via a queued `ConvertAcceptedBuyItem` job** dispatched at finalize: finalize returns fast (terminal + totals + SMS sync), drafts populate async, a job failure stamps `conversion_failed_at`, and `intake:retry-conversions` re-dispatches. Tests run `QUEUE_CONNECTION=sync` so the job runs inline + assertions hold. (Task 6, refines I12)
  - **N1** — `BuyItem` uses `HasUuid` **as-is** (it only generates when the key is empty, so a caller-supplied UUID is honored). Drop the "omit the trait / manual keyType" approach; just ensure `id` is fillable. (Task 1)
  - **N2** — add a regression test pinning post-attach blob semantics (the existing suite asserts neither presence nor 404 after attach). (Task 2)
  - **N3** — Ably capability wildcard `store:{storeId}:buy:*:items` passes through `requestToken` verbatim (confirmed) — `*` matches one segment; no change needed.

---

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

**Create:**
```
app/Support/Enums/BuyItemStatus.php
app/Support/Enums/BuyItemDisposition.php
app/Models/BuyItem.php
database/factories/BuyItemFactory.php
database/migrations/2026_06_24_000001_create_buy_items_table.php
database/migrations/2026_06_24_000002_add_settlement_fields_to_buys_table.php
app/Modules/Pos/Controllers/PosBuyItemController.php       # list/upsert/delete/resolve
app/Modules/Pos/Controllers/PosFinalizeController.php       # finalize
app/Modules/Pos/Requests/UpsertBuyItemRequest.php
app/Modules/Pos/Requests/ResolveBuyItemRequest.php
app/Modules/Pos/Requests/FinalizeBuyRequest.php
app/Modules/Pos/Resources/PosBuyItemResource.php
app/Modules/Pos/Services/BuyFinalizer.php                  # roll-up + totals + orchestration
app/Modules/Pos/Services/BuyCompletionNotifier.php         # shared opt_txn/once SMS gate (extracted from complete())
app/Modules/Pos/Services/IntakeListingConverter.php        # BuyItem -> draft Item (ItemService::create + Media::copy)
app/Modules/Pos/Jobs/ConvertAcceptedBuyItem.php            # queued: one accepted item -> draft listing (nonQueued conversions)
app/Modules/Pos/Observers/BuyItemBroadcaster.php           # item.upserted nudge
app/Console/Commands/RetryIntakeConversions.php            # intake:retry-conversions (re-dispatch failed)
tests/Feature/Pos/{BuyItemUpsertTest,BuyItemResolveTest,FinalizeBuyTest,IntakeConversionTest,BuyItemRealtimeTest}.php
tests/Feature/Capture/UploadMaterializesItemTest.php
tests/Unit/Pos/BuyFinalizerRollupTest.php
```

**Modify:**
```
app/Models/Buy.php                                  # items() relation, roll-up helpers, settlement fillable/casts
app/Modules/Capture/Controllers/CaptureUploadController.php  # store() upserts BuyItem; ack(attached) moves blob -> photos
app/Modules/Capture/Services/CaptureChannels.php    # buyItems() channel + posCapability wildcard subscribe
app/Modules/Pos/PosServiceProvider.php              # BuyItem::observe(BuyItemBroadcaster)
app/Modules/Pos/routes.php                          # new item + finalize routes
app/Modules/Pos/README.md                           # items domain section
contracts/openapi.yaml                              # new paths + schemas
api/CLAUDE.md                                        # note BuyItem
```

---

## Task 1 — Schema, enums, model

**Files:** the two migrations, `BuyItemStatus`, `BuyItemDisposition`, `BuyItem`, `BuyItemFactory`, `Buy` updates.

- [ ] **Step 1 (test, Postgres):** `tests/Feature/Pos/BuyItemSchemaTest.php` — a `BuyItem` persists with a **caller-supplied UUID** PK, FK to `buys`/`stores`/`categories`/`items`; money columns are unsigned; `status`/`disposition` cast to enums; `buys` gains `cash_total`/`store_credit_total`/`finalized_at`. Run on Postgres.
- [ ] **Step 2:** Migration `...000001_create_buy_items_table` per the spec data-model table (uuid PK **not** auto-generated, `buy_id` cascadeOnDelete + index, `store_id` index, nullable quote/listing columns, `converted_item_id` nullOnDelete, `conversion_failed_at`, `sort_order`, timestamps + softDeletes). Migration `...000002_add_settlement_fields_to_buys_table` (`cash_total`, `store_credit_total` unsignedInteger nullable, `finalized_at` timestamp nullable).
- [ ] **Step 3:** `BuyItemStatus` (`Pending`,`Quoted`,`Accepted`,`Declined` + `isTerminal()`), `BuyItemDisposition` (`ReturnedToCustomer`,`Recycled`).
- [ ] **Step 4 (review N1):** `BuyItem` model — `use HasUuid` **as-is** (`bootHasUuid` only generates when the key is empty, so a caller-supplied UUID is honored and an unset one auto-generates). Put `id` in `$fillable`. `HasMedia`/`InteractsWithMedia` with a `photos` collection (`image/jpeg`, on the media disk so it co-locates with marketplace media). Relations: `buy()`, `store()`, `category()`, `convertedItem()`. Fillable/casts. Helper `markQuoted()/markResolved()`. Validate `Str::isUuid` at the controller boundary, not in the model.
- [ ] **Step 5:** `Buy` — `items(): HasMany`, roll-up helpers `acceptedItems()`, `allItemsResolved()`, settlement columns in fillable/casts.
- [ ] **Step 6:** `BuyItemFactory` (states: `quoted()`, `accepted()`, `declined()`). Green on Postgres + SQLite. Pint. Commit.

## Task 2 — Capture upload integration (materialize + blob move)

**Files (modify):** `CaptureUploadController`. **Tests:** `tests/Feature/Capture/UploadMaterializesItemTest.php` + keep existing `UploadTest` green.

- [ ] **Step 1 (test, Postgres):** uploading a photo for an unknown `item_id` **materializes** a `BuyItem(id=item_id, buy_id, store_id, status=pending)` (idempotent on replay); an `attached` ack **copies** the capture blob into that item's `photos` collection with `slot` + `uploadId` as custom properties + appended order; the capture blob is **left in place** (review I2/N2 — assert it still exists after attach); existing Phase-1 upload/ack assertions still pass; a photo for an item whose buy is finalized → existing `item_finalized` reject.
- [ ] **Step 2 (review B1/I1):** In `store()`, materialize the `BuyItem` with an **atomic** `BuyItem::upsert([['id'=>$itemId,'buy_id'=>...,'store_id'=>...,'status'=>'pending']], ['id'], [])` (or `insertOrIgnore`) — **NOT** `firstOrCreate` inside the existing `DB::transaction` (the `item_id` race would 25P02-abort it). Prefer doing it before the upload transaction opens.
- [ ] **Step 3 (review B1/I2):** In `ack()`, after the transaction **commits** and only on the terminal `attached` transition (so it runs at most once), read the blob via `Storage::disk(config('capture.disk'))->get($upload->blob_path)` and `$buyItem->addMediaFromString($bytes)->usingFileName("{$uploadId}.jpg")->withCustomProperties(['slot'=>$upload->slot,'uploadId'=>$uploadId])->toMediaCollection('photos')` (cross-disk: capture `local` → media `public`). Idempotent — skip if a `photos` media with that `uploadId` custom property already exists. Best-effort (a media failure logs, never un-acks). Do **not** delete the capture blob (the POS `blob()` pull + capture retention own its lifecycle). Green (Postgres, faking **both** disks). Pint. Commit.

## Task 3 — POS quoting: upsert + list + delete

**Files:** `PosBuyItemController` (index/upsert/destroy), `UpsertBuyItemRequest`, `PosBuyItemResource`, `Pos/routes.php`. **Tests:** `BuyItemUpsertTest`.

- [ ] **Step 1 (test, Postgres):** `PUT /v1/pos/buys/{buy}/items/{item}` upserts (materialize-or-update) with quote metadata; setting an offer flips `pending→quoted` + stamps `quoted_at`; replay is idempotent; **store isolation** (cross-store buy/item → 404); `Str::isUuid` guard on `{item}`; `GET …/items` lists with photos (url+slot+order), quote, outcome, conversion state; `DELETE …/items/{item}` soft-deletes a pre-finalize item; mutating a finalized buy → 409.
- [ ] **Step 2:** `UpsertBuyItemRequest` — `title`/`description`/`brand`/`size` nullable strings; `category_id` nullable exists; `condition` in `ItemCondition`; `listing_price`/`cash_offer`/`store_credit_offer` nullable `integer min:0`. Controller asserts `buy.store_id===posStore.id`, `item.buy_id===buy.id`. `PosBuyItemResource` (id, status, disposition, quote fields, listing fields, `cash_offer`, `store_credit_offer`, `listing_price`, `converted_item_id`, `conversion_failed_at`, photos[{url,slot,order}], timestamps). Routes under `['pos.device','pos.store.active']`. Green (Postgres). Pint. Commit.

## Task 4 — POS resolution (per-item outcome)

**Files:** `PosBuyItemController::resolve`, `ResolveBuyItemRequest`. **Tests:** `BuyItemResolveTest`.

- [ ] **Step 1 (test, Postgres):** `POST …/items/{item}/resolve` with `outcome=accepted` sets `status=accepted`+`resolved_at`; `outcome=declined` **requires** `disposition∈{returned_to_customer,recycled}` (422 otherwise) and sets both; re-resolving a terminal item → 409; resolving on a finalized buy → 409; category NOT required here (I15).
- [ ] **Step 2:** Implement; `ResolveBuyItemRequest` conditional `disposition` rule. Green (Postgres). Pint. Commit.

## Task 5 — Finalize: roll-up + settlement totals

**Files:** `PosFinalizeController`, `FinalizeBuyRequest`, `BuyFinalizer`. **Tests:** `FinalizeBuyTest`, `tests/Unit/Pos/BuyFinalizerRollupTest.php`.

- [ ] **Step 1 (tests, Postgres):** `POST /v1/pos/buys/{buy}/finalize` — 422 (naming offenders) if any item is **unresolved**, OR any **accepted** item lacks any of `title`/`condition`/`category_id`/`listing_price` (review B2 — all required by the `items` table / `ItemService::create`); roll-up (unit test): ≥1 accepted→`accepted`; items exist & none accepted→`no_buy`; `customer_rejected=true` flag→`declined`; records `cash_total`/`store_credit_total` = sum over accepted; sets `finalized_at`; fires the completion SMS once (gated on `opt_txn`, guarded by `completed_notified_at`); duplicate finalize → no-op (no 2nd SMS). Conversion is Task 6 — assert finalize **dispatches** a `ConvertAcceptedBuyItem` job per accepted item.
- [ ] **Step 2 (review I4):** Extract `PosBuyController::notifyCompletion()` into a shared `BuyCompletionNotifier` service (opt_txn + `completed_notified_at` + phone guards); have **both** `complete()` and `finalize` call it. `BuyFinalizer::finalize(Buy, bool $customerRejected): Buy` — validate, compute terminal `BuyStatus`, write totals, transition, notify, dispatch conversion jobs. `FinalizeBuyRequest` (`customer_rejected` bool). Wrap state changes in a transaction (conversion is async, outside it). Green (Postgres). Pint. Commit.

## Task 6 — Listing conversion (queued) + retry

**Files:** `ConvertAcceptedBuyItem` (queued job), `IntakeListingConverter`, `RetryIntakeConversions`. **Tests:** `IntakeConversionTest` (runs `QUEUE_CONNECTION=sync` so the job executes inline).

- [ ] **Step 1 (tests, Postgres):** a dispatched `ConvertAcceptedBuyItem` for an accepted item → a draft `Item` via **`ItemService::create($store, $store->owner, [...])`** (review I3: forces `Draft`, no events) with `title`/`description`/`category_id`/`brand`/`size`/`condition` + `price = listing_price`; each `photos` `Media` is **copied** into the `Item` `images` collection via `$media->copy($item, 'images')` (assert media count + that the four `Item` conversions regenerated); `buy_item.converted_item_id` set; **idempotent** (re-run skips an already-converted item). **Partial failure (I12):** force one item's copy to throw → that item is stamped `conversion_failed_at` and logged, the buy is already finalized + the SMS already sent (conversion is async, review I5), other items still convert; `intake:retry-conversions` re-dispatches only `conversion_failed_at` items and clears the marker on success.
- [ ] **Step 2 (review I3/I5):** Implement `ConvertAcceptedBuyItem implements ShouldQueue` wrapping `IntakeListingConverter::convert(BuyItem): Item`. Converter: guard `converted_item_id` null + status accepted; `ItemService::create(...)`; `foreach ($buyItem->getMedia('photos') as $m) $m->copy($item, 'images');` (order preserved; the intake `slot`/`uploadId` custom properties are dropped — listings don't use slots); set `converted_item_id`; clear `conversion_failed_at`. On throw: stamp `conversion_failed_at`, log, don't rethrow past the retry budget. `RetryIntakeConversions` (`intake:retry-conversions`) re-dispatches jobs for `conversion_failed_at` items. Green (Postgres). Pint. Commit.

## Task 7 — Realtime item nudges

**Files:** `BuyItemBroadcaster`, `PosServiceProvider`, `CaptureChannels`. **Tests:** `BuyItemRealtimeTest` + extend `RealtimeTokenTest`.

- [ ] **Step 1 (tests):** creating/quoting/resolving a `BuyItem` publishes `item.upserted` on `store:{storeId}:buy:{buyId}:items` (assert via `FakeAblyPublisher`); a non-meaningful update doesn't double-publish; a publish failure never breaks the write (best-effort). The POS token capability grants `subscribe` on `store:{storeId}:buy:*:items` (decode `FakeAblyTokenIssuer`, as in the queue-push test).
- [ ] **Step 2:** `CaptureChannels::buyItems(storeId, buyId)` + a `buyItemsWildcard(storeId)` for the capability; add `subscribe` on the wildcard to `posCapability`. `BuyItemBroadcaster` (created + updated-when-relevant) mirrors `BuyQueueBroadcaster` (best-effort, payload `{action,id,buyId,status,...,ts}`). Register `BuyItem::observe(...)` in `PosServiceProvider`. Green (Postgres). Pint. Commit.

## Task 8 — OpenAPI + final sweep

**Files:** `contracts/openapi.yaml`, `Pos/README.md`, `api/CLAUDE.md`.

- [ ] **Step 1:** Add paths (`GET/PUT/DELETE /v1/pos/buys/{buy}/items[/{item}]`, `POST …/resolve`, `POST /v1/pos/buys/{buy}/finalize`) + component schemas (`PosBuyItem`, `FinalizeBuyRequest`). Document 200/201/409/422 responses + the finalize roll-up.
- [ ] **Step 2:** Update `Pos/README.md` (items domain + finalize + conversion + realtime) and `api/CLAUDE.md`. **Full suite on Postgres** + Pint + phpstan (no new errors). Commit. Open PR `feature/intake-items` → `feat/capture-phase1` (rebase onto `main` once the capture branch lands). The spec decisions (I1–I15) are the review baseline.

---

## Resolved by the codex plan-review (v2)
- **Item creation reuse** → use `ItemService::create()` (forces `Draft`, no events/Scout/jobs; a Draft never indexes — `shouldBeSearchable` requires `Active`). Avoid `snapPublish()`. *(review I3)*
- **Media mechanics** → two operations: raw blob → `BuyItem.photos` via `addMediaFromString` (Task 2); `BuyItem.photos` Media → `Item.images` via `Media::copy` (Task 6). Cross-disk (`local`→`public`); conversions are `nonQueued` ⇒ run the convert in a queued job. *(review B1/I5)*
- **Item required fields** → `finalize` validates `title`+`condition`+`category_id`+`listing_price` on accepted items. *(review B2)*
- **Capture path coupling** → `store()` uses an atomic upsert (no 25P02), `ack()` copies after commit + leaves the blob. Frozen contract preserved; add a post-attach blob test. *(review I1/I2/N2)*

## Remaining notes for review
- **Buy lifecycle statuses:** this milestone uses `queued`→terminal directly; the intermediate `sorting/sorted/in_progress/quoted` enum cases remain optional UI sugar (flag if reviewers want explicit transitions wired).
- **Async-convert visibility:** finalize returns before drafts exist (jobs run after). Confirm the POS/seller UX tolerates an eventual-consistency window for the auto-created listings (the buy is already terminal + SMS sent).
