<!-- title: Procurement lifecycle | order: 4 | summary: From vehicle prep to a confirmed order — the state machine, plus the proposed extensions beyond it. -->

# Procurement lifecycle

This page walks the parts-procurement lifecycle of **Rails Sandbox — a Rails-compatible API** (a faithful mock of the public Integrations API, plus clearly-labelled proposed extensions). It names every **dotted wire method** in order, the **status** each produces, and the exact point where the public `2026-01` contract ends.

Every example below is an RPC-over-POST call:

```
POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/<dotted.method>
Authorization: Bearer <api_key>
Partly-Integration-ID: <integration_id>
Content-Type: application/json
```

See [Quickstart](./quickstart.md) for credentials and a first call, the [API Reference](./api-reference.md) for full request/response shapes, and [Errors](./errors.md) for the coded failure model.

---

## The two state machines

Two things move through states: **parts** and **procurements**. The enum values below are quoted verbatim from the contract — do not invent others.

**Part status** — `PartStatusV1`:

```
"estimated" | "ordered"
```

A newly inserted part starts `"estimated"`. It transitions to `"ordered"` once a procurement has been placed for it.

**Procurement status** — `procurements.v1.buyer.Status`:

```
"order_requested" | "order_confirmed" | "cancelled"
```

A procurement is created in `"order_requested"` and is transitioned to `"order_confirmed"` by the supplier. `"order_confirmed"` is the **terminal public state** — see the boundary marker below. (`"cancelled"` is enumerated in the contract; the sandbox lifecycle does not drive a cancellation path.)

ASCII view of the happy path:

```text
                 ── PUBLIC 2026-01 CONTRACT ──────────────────────────┐
 part: estimated ──(place)──► ordered                                 │
                                                                      │
 procurement:  (create) ──► order_requested ──(supplier confirms)──►  │
                                                       order_confirmed │  ◄── terminal public state
 ──────────────────────────────────────────────────────────────────  ┘
                                                          │
                  ── PROPOSED EXTENSIONS (beyond 2026-01) ┴── (see below)
                                  invoices appear after order_confirmed
```

---

## Ordered walkthrough — `tier1.prepare` → `order_confirmed`

The faithful path uses these dotted wire methods, in workflow order. Read-only resolution helpers (`repairer.work-providers.list`, plus the job-read methods) are listed where they fit.

### 1. `tier1.prepare` — warm the catalogue (no state change)

Queues a vehicle VIN/VRM for future use in other the API (bulk of up to 1000). Fire-and-forget: returns `{ invalid_identifiers }` and makes **no state change** to any job, part, or procurement.

```bash
curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/tier1.prepare \
  -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \
  -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{ "identifiers": [{ "vin": "JTDBR32E730012345" }] }'
```

### 2. `repairer.work-providers.list` — resolve the insurer (read-only)

Fetches the list of work providers (insurers) for a repairer so you can attach the right `work_provider_id` to a job. Empty request body. Omit the work provider entirely for cash / self-pay jobs.

### 3. `repairer.jobs.insert` — open or update a repair job

Upserts a repair job (insert-or-update keyed by identity). On success it emits a `repairer.jobs` webhook whose payload carries `change_action` of `"inserted"` or `"updated"` (see [Webhooks](./webhooks.md)). No procurement exists yet.

```bash
curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/repairer.jobs.insert \
  -H "Authorization: Bearer partly_demo_repairer_3f8a1c0d9e2b4a67b1c2" \
  -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
    "identity": { "external": "CCC-2026-04817" },
    "repairer_site_id": "0b000000-0000-4000-8000-000000000001",
    "work_provider_id": "0a000000-0000-4000-8000-000000000020"
  }'
```

### 4. `repairer.jobs.parts.insert` — identify parts

A **full-list upsert** of the job's parts, keyed by part identity. Each newly inserted part starts at `status: "estimated"`.

> Wire note: `repairer.jobs.parts.insert` is the one method whose error bodies are **bare JSON strings** (`"not_found"`, `"unprocessable"`, …), not `{ "type": "..." }` objects. This asymmetry is reproduced faithfully from the contract; see [Errors](./errors.md).

### 5. `repairer.jobs.baskets.latest.get` — recommend a basket (read-only)

Fetches the latest priced basket for the job. Returns:

```json
{ "offers": [], "suppliers": [] }
```

A job that exists but has no priced parts returns an **empty basket**, not an error.

> **Currency:** the basket response carries **no `currency` field**. `OfferV1` exposes `trade_price` (a bare string, NZD ex-GST) with no accompanying currency, and the basket envelope has only `offers` and `suppliers`. Currency is a procurement-level concept — it first appears on `procurements.v1.buyer.Procurement.currency_code` (`"NZD"`). This is decision **D52**: any currency shown alongside a basket offer is invented. The Rails MCP's projected basket may attach a *derived presentation-layer* `currency` label, but that is never a wire claim.

### 6. `repairer.procurements.get` — track a procurement (read-only)

Buyer/repairer view of one procurement by UUID: its status and lines. A procurement belonging to a different org resolves as `not_found` (scoped read).

### 7. `supplier.procurements.confirm` — confirm the order

Transitions a procurement `order_requested → order_confirmed`. This is a **supplier-scope** action: it requires a credential whose `scope` is `"supplier"`. On confirm the order is **locked**, an invoice is created, and buyer + supplier `…procurements` webhooks fire with status `order_confirmed`.

The seed ships one procurement already placed in `order_requested` — **PR-1**, id `10000000-0000-4000-8000-000000000001` — so you can exercise confirm immediately, using the **supplier** credential:

```bash
curl -X POST https://partifact-mock-rails.thanhvuttv.workers.dev/api/2026-01/supplier.procurements.confirm \
  -H "Authorization: Bearer partly_demo_supplier_8b4e2f1a6c0d3e9f7a25" \
  -H "Partly-Integration-ID: 0c000000-0000-4000-8000-000000000002" \
  -H "Content-Type: application/json" \
  -d '{ "identity": { "id": "10000000-0000-4000-8000-000000000001" } }'
```

> The supplier and repairer credentials are both preloaded by the seed, so a single caller can drive both sides by swapping the two header pairs. See [Quickstart](./quickstart.md).

### Lifecycle summary table

| # | Dotted wire method | Faithful? | Resulting status / effect |
|---|---|---|---|
| 1 | `tier1.prepare` | faithful | Catalogue warmup; returns `{ invalid_identifiers }`; **no state change** |
| 2 | `repairer.work-providers.list` | faithful | Read-only; lists insurers for the repairer |
| 3 | `repairer.jobs.insert` | faithful | Upserts job; emits `repairer.jobs` webhook (`change_action`) |
| 4 | `repairer.jobs.parts.insert` | faithful | Full-list upsert of parts; new parts start `estimated` |
| 5 | `repairer.jobs.baskets.latest.get` | faithful | Read-only; `{ offers, suppliers }`; empty (not an error) if no basket. **No `currency` field** |
| 6 | `repairer.procurements.get` | faithful | Read-only buyer view by UUID; cross-org → `not_found` |
| 7 | `supplier.procurements.confirm` | faithful | `order_requested → order_confirmed`; locks order; creates invoice; fires webhooks |

---

> **── End of the public 2026-01 contract: `order_confirmed` is the terminal state. ──**

Everything above is a faithful mirror of the public `2026-01` surface. The public lifecycle **terminates at `order_confirmed`**. The methods below are **not** part of that contract.

---

## Proposed extensions (beyond 2026-01)

These two steps complete the buyer's procurement loop — placing the order and reconciling the resulting invoice — but the public `2026-01` API exposes **no public method** for either. the upstream platform performs procurement-placement and invoice-reconciliation internally; here they are reconstructed as a **clearly-labelled proposed extension** so the end-to-end loop is demonstrable, never passed off as the real contract.

Every extension method announces itself: its HTTP response carries `x_extension: true`, and the Rails MCP result carries `is_extension: true`. Both extension surfaces open with this verbatim banner:

> (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.)

### `repairer.procurements.insert` — place a procurement (proposed extension)

> (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.)

Creates a procurement in `status: "order_requested"` from a selection of offers, and moves the selected parts `estimated → ordered`. It emits buyer + supplier `…procurements` webhooks (status `order_requested`). This is the step that, in the faithful path, the seed has already performed for PR-1; for a brand-new job you place first, then confirm.

The full proposed-extension workflow order is therefore:

```text
prepare_vehicle → open_repair_job → identify_parts → recommend_basket
   → place_procurement (ext) → confirm_procurement → reconcile_invoice (ext)
```

#### Single-confirmable-supplier constraint

The seed has **three supplier orgs** but **one supplier credential** — **S1, "Christchurch Toyota — Parts"**, business_id `0b000000-0000-4000-8000-000000000010`. A supplier can only confirm its **own** orders. Because this demo holds only S1's supplier credential, **only a procurement placed from S1's offers can ever reach `order_confirmed`**.

Where that constraint bites depends on which surface you use:

- **On the raw wire**, `repairer.procurements.insert` is **permissive** — it will happily create a procurement from S2 / S3 offers and return `order_requested`. The mismatch only surfaces later, at **confirm** time: `supplier.procurements.confirm` returns `not_found` because no seeded credential can act as that supplier, and the order is left stranded in `order_requested`.
- **Through the Rails MCP**, the `place_procurement` tool adds a client-side guard (decision **D53**): if any selected offer's `supplier_business_id` is not S1's, it **fails fast before the wire** with an `unprocessable` extension error that names the only confirmable supplier and points you back to the basket — so an agent never creates the dead-end order in the first place. This guard lives in the MCP tool, not in the wire contract.

See [Errors](./errors.md) for the exact recovery text.

### `repairer.procurements.invoices.list` — reconcile invoices (proposed extension)

> (Proposed extension beyond the public 2026-01 API — the public contract stops at `order_confirmed`; this completes the buyer loop.)

Reads reconciled invoices and credit notes for a procurement (or job). An invoice is created on confirm with `status: "reconciled"`, `document_type: "invoice"`, and each line `reconciliation: "matched"`. Invoices therefore appear **only after `order_confirmed`** — calling this before the order is confirmed returns nothing to reconcile.

---

## Re-reading state — never assume, re-fetch

The lifecycle is event-driven and webhooks are identifier-only (payloads carry ids, not records), so after any transition you re-fetch the current truth by id:

- **`repairer.jobs.get`** — the job **header** (vehicle, `repairer_site_id`, `work_provider_id`, `claim_number`, images) by `external` reference (e.g. `CCC-2026-04817`) or `id`. The wire `jobs.get` does **not** embed parts, procurements, or invoices — read those separately via `repairer.jobs.parts.list`, `repairer.procurements.get`, and `repairer.procurements.invoices.list`. (The Rails MCP's `get_job` aggregates all of them into one composite view; the wire method does not.) The natural first call to orient yourself on the seeded job.
- **`repairer.procurements.get`** — buyer-view read of one procurement's status and lines by UUID. Call it after a place or confirm to read the new status rather than inferring it.

After you confirm, call `repairer.procurements.get` for the new truth (`order_confirmed`), and `repairer.jobs.parts.list` to see the ordered parts flipped to `ordered`.

---

## See also

- [Quickstart](./quickstart.md) — credentials, headers, and your first call.
- [API Reference](./api-reference.md) — full request/response schemas for every method.
- [Errors](./errors.md) — the coded error model, the bare-string asymmetry, and retryability.
- [Webhooks](./webhooks.md) — the events emitted at each transition.
- Machine-readable: [/openapi.json](/openapi.json), and the LLM digests [/llms.txt](/llms.txt) and [/llms-full.txt](/llms-full.txt).
