# commerce-api wire contract — hand-curated, the single source of truth for
# what this service exposes over HTTP (same discipline as backend/openapi.yaml
# for customer-api). Fields are on the wire only if they are in this spec;
# the contract test in tests/openapi_contract.rs enforces it.
#
# Consumers:
#   - packages/commerce-types (openapi-typescript) → stixel_editor, stixel_admin
#   - iOS via swift-openapi-generator (openapi.json, regenerated by
#     scripts/regen_openapi.py / `just docs`)
openapi: 3.1.0
info:
  title: Stixel Commerce API
  description: >
    Orders and money for Stixel Maker (epic BRC-61). Postgres-backed Rust
    service; the authority on order lifecycle from payment through delivery.
    Stripe is the financial source of truth — this API records and
    orchestrates, it never computes money it didn't get from Stripe.

    An order carries two independent lifecycle tracks (BRC-75):
    `payment_status` (where the money is, written by Stripe events) and
    `fulfillment_state` (where the print is, written by the packer, the
    operator, and Gooten). A partial refund is a fact about the money and
    leaves the print side alone.
  version: 0.1.0
servers:
  - url: https://commerce.stixelmaker.com
    description: Production
  - url: http://localhost:8002
    description: Local development

paths:
  /checkout/session:
    post:
      operationId: createCheckoutSession
      summary: Create a hosted Stripe Checkout Session for a draft project
      description: >
        The web checkout head (BRC-64). The server re-derives the
        authoritative quote from the sticker API (`GET /projects/{id}/quote`)
        and prices it from the commerce table — nothing the client sends
        influences the price. A `pending` order row is written before the
        Stripe call, so an abandoned checkout is a visible fact; the webhook
        flips its `payment_status` to `paid`. Stripe owns everything after the redirect: card
        entry, address, shipping choice, promotion codes, tax.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CheckoutSessionRequest"
      responses:
        "200":
          description: Session created; redirect the customer to `url`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSessionResponse"
        "400":
          description: Missing project id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "404":
          description: The sticker API knows no such project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "409":
          description: >
            Either the project already has a live (paid, unrefunded) order —
            buying it again would double-charge — or it has no quotable
            sticker lines (`error` distinguishes the two).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "500":
          description: Storage failure, or catalog drift between services.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "502":
          description: The sticker API or Stripe is unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }

  /checkout/mobile-intent:
    post:
      operationId: createMobileIntent
      summary: Create a PaymentIntent for the iOS PaymentSheet
      description: >
        The native counterpart of `/checkout/session` (BRC-66). Same
        server-derived quote, same pricing table, same pending order, same
        webhook pipeline — only the Stripe object differs: a bare
        PaymentIntent the app presents via PaymentSheet (Apple Pay, cards).
        The app collects email, shipping choice and address in its own UI
        and sends them here; the server states the amount (lines + flat
        shipping). `tax_cents` is 0 until Stripe Tax is wired to this path
        (decided in BRC-66; tightened with BRC-30) — the field exists so the
        shape won't change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MobileIntentRequest"
      responses:
        "200":
          description: Intent created; present PaymentSheet with `client_secret`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MobileIntentResponse"
        "400":
          description: Missing project id or unknown shipping code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "404":
          description: The sticker API knows no such project.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "409":
          description: Live order exists, or no quotable lines.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "500":
          description: Storage failure, or catalog drift between services.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }
        "502":
          description: The sticker API or Stripe is unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutErrorResponse" }

  /orders/{id}/status:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getOrderStatus
      summary: Customer-facing order status (capability-scoped)
      description: >
        Polled by the post-checkout success page (BRC-70). The order UUID is
        the capability — unguessable, handed only to the checkout's creator
        in the success URL, the same model as project ids. Customer-safe on
        purpose: no email, address, or Stripe ids.
      responses:
        "200":
          description: Where the order is.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatus"
        "404":
          description: Unknown or malformed order id.
        "500":
          description: Storage failure.

  /admin/orders:
    get:
      operationId: adminListOrders
      summary: Recent orders, newest first (operator)
      security: [{ BearerAuth: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, maximum: 200 }
      responses:
        "200":
          description: Order summaries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminOrderSummary" }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/queue:
    get:
      operationId: adminOrdersQueue
      summary: The worklist — every paid, unshipped, living order (operator)
      description: >
        Oldest debt first: money held (`paid` or `partially_refunded`) and
        not yet shipped. The fulfillment state is the reason a row is
        listed — `awaiting_layout` needs a human most urgently, `none`
        means the post-payment pipeline stalled. An unreadable row fails
        the request loudly rather than presenting an empty desk.
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Outstanding orders.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AdminOrderSummary" }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: adminOrderDetail
      summary: One order in full — row, charged lines, timeline (operator)
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: The order with its lines and audit events.
          content:
            application/json:
              schema:
                type: object
                required: [order, items, events]
                properties:
                  order: { $ref: "#/components/schemas/AdminOrder" }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/AdminOrderItem" }
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/AdminOrderEvent" }
        "404": { description: No such order. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}/approve:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminApproveOrder
      summary: Approve for fulfillment — gated by the live tally (operator)
      description: >
        `in_review → approved`. The tally is fetched from the sticker API at
        approval time, never from a cached value: a layout that would
        deliver fewer stickers than were paid for is refused with 409
        `layout_under_delivers` + `short_lines`. No tally, no approval
        (502 `tally_unavailable`) — refusing beats approving blind. What
        was approved (sheets + per-line tally) is snapshotted onto the
        order as `approved_layout`; submit compares against it.
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Approved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LifecyclePosition"
        "404": { description: No such order. }
        "409":
          description: Layout under-delivers, or illegal transition.
        "502": { description: Tally or fulfillment info unavailable from the sticker API. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}/submit:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminSubmitOrder
      summary: Submit an approved order to Gooten (operator)
      description: >
        One recipe minted per sheet (an order packed across sheets prints a
        fraction of itself if only the first is submitted), then the Gooten
        order, then fulfillment `approved → submitted` — with an audit event carrying
        the external id, exactly which sheets shipped, and what was charged
        (the packer makes those genuinely different). Dry-run (no
        GOOTEN_API_KEY, or GOOTEN_DRY_RUN=1) returns a synthetic external id
        and skips the unrendered-sheet guard so dev flows complete without a
        rendered master. Live submissions send IsTest until
        GOOTEN_LIVE_ORDERS=1 opts in.
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Submitted (or dry-run recorded).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/LifecyclePosition"
                  - type: object
                    required: [external_id, dry_run, submitted_sheets]
                    properties:
                      external_id: { type: string }
                      dry_run: { type: boolean }
                      submitted_sheets:
                        type: array
                        items:
                          type: object
                          required: [sheet_id, medium, pack, print_asset_url]
                          properties:
                            sheet_id: { type: string }
                            medium: { type: [string, "null"] }
                            pack: { type: integer }
                            print_asset_url: { type: [string, "null"] }
        "409":
          description: >
            Not approved (`not_approved`); money no longer held — refunded
            or cancelled (`payment_not_held`); the layout changed since it
            was approved or now under-delivers (`layout_changed` + `changes`
            + `short_lines` — the approval is withdrawn and the order is
            back in `in_review`); sheets missing print masters
            (`sheets_unrendered` + `sheet_ids`); or nothing to print.
        "500":
          description: >
            Storage failure, catalog drift (`catalog_drift`), or — worst
            case — the Gooten order was placed but the row did not move
            (`submitted_but_not_recorded`; the timeline carries the
            external id, reconcile by hand, do not resubmit).
        "502": { description: Sticker API (tally or fulfillment info) or Gooten unreachable. }
        "404": { description: No such order. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}/layout:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminSetLayout
      summary: Record a layout hand-off on the print track (operator)
      description: >
        The design work happens on the board; this tells the order what it
        meant. `needs_manual_layout`: the pack is no good — `in_review` or
        `approved` → `awaiting_layout`. `ready_for_review`: a layout wants
        proofing — `awaiting_layout` or `approved` → `in_review`. Leaving
        `approved` withdraws the approval and its snapshot. Illegal from
        anywhere else (e.g. once submitted).
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outcome]
              properties:
                outcome:
                  type: string
                  enum: [needs_manual_layout, ready_for_review]
      responses:
        "200":
          description: Moved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LifecyclePosition"
        "409": { description: Illegal transition from the current state. }
        "404": { description: No such order. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}/deliver:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminDeliverOrder
      summary: Confirm delivery — `shipped → delivered` (operator)
      description: Manual for v1; the operator confirms from carrier tracking.
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: Delivered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LifecyclePosition"
        "409": { description: Not shipped. }
        "404": { description: No such order. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /admin/orders/{id}/retry-followups:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminRetryFollowups
      summary: Re-run the post-payment follow-ups for a stalled order (operator)
      description: >
        For an order that is paid but whose print track is still `none`: the
        webhook's hand-off to the sticker API (`mark_ordered`) failed and
        nothing else will move it — Stripe does not resend the payment event.
        Runs the same phase-2 steps the webhook ran (confirmation email only
        if none was sent, mark-ordered, the first print transition, render
        trigger). Annotates `followups_retried` with the operator.
      security: [{ BearerAuth: [] }]
      responses:
        "200":
          description: >
            The lifecycle after the attempt. `followup_error` is null when the
            pipeline advanced; otherwise the step that still fails and why.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetryFollowupsResult"
        "409": { description: Not stalled — unpaid, already past `none`, or a duplicate payment (`not_stalled`). }
        "404": { description: No such order. }
        "400": { description: Malformed order id (`invalid_order_id`). }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }

  /admin/orders/{id}/tracking:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      operationId: adminSetTracking
      summary: Record carrier + tracking number — ships the order (operator)
      description: >
        Fulfillment `submitted → shipped`, with the tracking fields and the
        transition in one transaction so a shipped order always has its
        number.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [carrier, tracking_number]
              properties:
                carrier: { type: string }
                tracking_number: { type: string }
      responses:
        "200":
          description: Shipped.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/LifecyclePosition"
                  - type: object
                    required: [carrier, tracking_number]
                    properties:
                      carrier: { type: [string, "null"] }
                      tracking_number: { type: [string, "null"] }
        "400":
          description: >
            Malformed order id (`invalid_order_id`) or empty carrier /
            tracking number (`missing_tracking_fields`).
        "409": { description: Illegal transition (not yet submitted). }
        "404": { description: No such order. }
        "500": { description: Storage failure. }
        "401": { description: Missing or invalid operator token. }
        "403": { description: Authenticated but not a Stixelize operator. }

  /webhooks/stripe:
    post:
      operationId: stripeWebhook
      summary: Stripe event delivery (Stripe-to-server only)
      description: >
        Consumed by Stripe, never by our clients — documented for
        completeness. Verifies the `Stripe-Signature` header (HMAC-SHA256,
        5-minute tolerance) against `STRIPE_WEBHOOK_SECRET`. Handles
        `checkout.session.completed` and `payment_intent.succeeded`
        (record Stripe's charged amounts and shipping address, flip
        `payment_status` to `paid`, and run the sticker pipeline — but only
        when the session's `payment_status` is `paid` or
        `no_payment_required`; a delayed-notification method's `unpaid`
        completion records the facts and waits for
        `checkout.session.async_payment_succeeded` / `…_failed`),
        `charge.refunded` (records the cumulative `refunded_cents`;
        `payment_status` → `partially_refunded` or `refunded`, the print
        side untouched), and `refund.updated` with a failed or canceled
        status (the money is back on the charge: `refunded_cents` drops
        and `payment_status` returns to `paid` / `partially_refunded`).
        Idempotent by
        event id: the processed-event ledger commits in the same transaction
        as the effects, so at-least-once delivery is safe. Non-2xx responses
        make Stripe redeliver with backoff — returned deliberately when an
        event can't yet be attributed to an order.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A Stripe event envelope (opaque to this spec).
      responses:
        "200":
          description: Processed, duplicate, or ignored — do not redeliver.
        "400":
          description: Bad signature or unparseable body.
        "500":
          description: Not yet processable — redeliver later.

  /prices:
    get:
      operationId: getPrices
      summary: The public price table (sheet prices per medium × pack, flat shipping)
      description: >
        What the storefront shows beside a quote before any order exists
        (BRC-81) — the role Medusa's variant list used to play. Static
        constants (`pricing.rs`), so the response carries
        `Cache-Control: public, max-age=300`. Display only: checkout re-prices
        server-side from the same table and Stripe records what was charged.
      responses:
        "200":
          description: Every sellable (medium × pack) and both shipping rates.
          headers:
            Cache-Control:
              schema: { type: string }
              description: "`public, max-age=300`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PriceList"

  /me/orders:
    get:
      operationId: getMyOrders
      summary: The signed-in customer's orders, newest first
      description: >
        Requires the customer's Stytch **consumer** session JWT as
        `Authorization: Bearer`. Returns orders stamped with their Stytch id
        at checkout **and** orders bought anonymously with the address
        Stytch verified for this session — the common path, because the
        storefront sells without an account. Unpaid, abandoned checkouts are
        omitted. Customer-safe: no Stripe ids, no operator annotations.
      security: [{ CustomerAuth: [] }]
      responses:
        "200":
          description: Up to 100 orders, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/CustomerOrder"
        "401": { description: Missing or invalid customer session token. }
        "500": { description: Storage failure. }

  /me/orders/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getMyOrder
      summary: One of the signed-in customer's orders
      description: >
        Same ownership rule as the list. An order that is not theirs answers
        404, not 403 — it is not theirs to know about.
      security: [{ CustomerAuth: [] }]
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CustomerOrder"
        "404": { description: No such order for this customer. }
        "401": { description: Missing or invalid customer session token. }
        "500": { description: Storage failure. }

  /me/orders/link:
    delete:
      operationId: unlinkMyOrders
      summary: Forget the customer behind their orders
      description: |
        Called by the sticker API when someone deletes their account
        (App Store Review Guideline 5.1.1(v)).

        An order row is an accounting record and survives; what a deleted
        customer is entitled to is that it stops being *theirs*. The Stytch
        id, the email and the shipping address come off; the money, the line
        items and the lifecycle stay exactly as they were.

        Stripping the email matters as much as the id: `/me/orders` matches
        on either, so an order that kept its address would be handed to
        whoever signs up with that address next.

        Authenticated as the customer, with the same consumer session token
        every other `/me` route takes — the sticker API forwards the
        caller's own token rather than acting on their behalf with operator
        credentials.
      tags: [Customer]
      security:
        - CustomerAuth: []
      responses:
        "200":
          description: What was forgotten, and what must survive it.
          content:
            application/json:
              schema:
                type: object
                required: [unlinked, retained_projects]
                properties:
                  unlinked:
                    type: integer
                    description: Orders that were this customer's and no longer are.
                  retained_projects:
                    type: array
                    items: { type: string }
                    description: >
                      Projects the sticker API must keep rather than purge
                      with the rest of the customer's design data: an order
                      has been paid for and not yet delivered, and still has
                      to be printed from them. Destroying that artwork
                      leaves a paid order unfulfillable, with no recovery
                      but asking a customer who no longer has an account to
                      send their photos again.
        "401": { description: Missing or invalid customer session token. }
        "500": { description: Storage failure. }

  /health:
    get:
      operationId: getHealth
      summary: Liveness, including a live Postgres round trip
      description: >
        Returns 200 only when the service can actually reach its database —
        a commerce API that can't reach Postgres can't take an order, and
        load balancers should treat it as down.
      responses:
        "200":
          description: Service and database are reachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Postgres is unreachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

components:
  securitySchemes:
    CustomerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        The customer's Stytch **consumer** session JWT (a different project,
        issuer and JWKS from the operators' B2B tokens).
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Stytch B2B session JWT for a Stixelize Operator — the same token
        stixel_admin sends admin-api; verification is the shared
        stytch-auth guard.

  schemas:
    PaymentStatus:
      type: string
      description: >
        Where the money is. Written by Stripe events (and by checkout
        supersession for `cancelled`). `pending → paid`, then refunds move
        it to `partially_refunded` (still live: the order still ships and
        still blocks a second checkout for the project) or `refunded`.
        Both refunded statuses move back toward `paid` if Stripe later
        reports the refund failed. `cancelled` is an abandoned or
        superseded checkout, reachable only from `pending`; a superseded
        session Stripe still let the customer pay becomes `paid` (and a
        duplicate, if another order is live).
      enum:
        - pending
        - paid
        - partially_refunded
        - refunded
        - cancelled
    FulfillmentState:
      type: string
      description: >
        Where the print is. Only advances while money is held. `none`
        before payment, and after payment until the packer reports;
        then awaiting_layout / in_review → approved → submitted →
        shipped → delivered. in_review ⇄ awaiting_layout and
        approved → in_review are the proofing regressions.
      enum:
        - none
        - awaiting_layout
        - in_review
        - approved
        - submitted
        - shipped
        - delivered
    LifecyclePosition:
      type: object
      description: The two tracks, as every mutating operator route reports them back.
      required: [payment_status, fulfillment_state]
      properties:
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
    CustomerOrderItem:
      type: object
      required: [title, description, quantity, unit_amount_cents, image_url]
      properties:
        title: { type: string }
        description: { type: string }
        quantity: { type: integer, format: int32 }
        unit_amount_cents: { type: integer, format: int64 }
        image_url:
          type: [string, "null"]
          description: The line's cut-out, captured at checkout.

    CustomerOrder:
      type: object
      description: One of the customer's own orders — safe to show them in full.
      required:
        [
          order_number,
          id,
          created_at,
          payment_status,
          fulfillment_state,
          duplicate_payment,
          currency,
          subtotal_cents,
          shipping_cents,
          tax_cents,
          total_cents,
          refunded_cents,
          carrier,
          tracking_number,
          shipped_at,
          receipt_url,
          ship_name,
          ship_line1,
          ship_line2,
          ship_city,
          ship_state,
          ship_postal_code,
          ship_country,
          project_id,
          items,
        ]
      properties:
        order_number:
          type: string
          description: "`#3F2A9C1B` — what the emails call it."
        id: { type: string, format: uuid }
        created_at: { type: string }
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
        duplicate_payment:
          type: boolean
          description: A second payment for a design already bought; it is refunded and nothing prints.
        currency: { type: string }
        subtotal_cents: { type: [integer, "null"], format: int64 }
        shipping_cents: { type: [integer, "null"], format: int64 }
        tax_cents: { type: [integer, "null"], format: int64 }
        total_cents: { type: [integer, "null"], format: int64 }
        refunded_cents: { type: integer, format: int64 }
        carrier: { type: [string, "null"] }
        tracking_number: { type: [string, "null"] }
        shipped_at: { type: [string, "null"] }
        receipt_url:
          type: [string, "null"]
          description: Stripe's own receipt for the charge.
        ship_name: { type: [string, "null"] }
        ship_line1: { type: [string, "null"] }
        ship_line2: { type: [string, "null"] }
        ship_city: { type: [string, "null"] }
        ship_state: { type: [string, "null"] }
        ship_postal_code: { type: [string, "null"] }
        ship_country: { type: [string, "null"] }
        project_id:
          type: string
          description: The design, so "Order again" can rebuild the basket from it.
        items:
          type: array
          items:
            $ref: "#/components/schemas/CustomerOrderItem"

    RetryFollowupsResult:
      type: object
      required: [payment_status, fulfillment_state, followup_error]
      properties:
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
        followup_error:
          type: [object, "null"]
          description: The step that stopped the pipeline and its error; null when it advanced.
          required: [step, error]
          properties:
            step: { type: string, enum: [mark_ordered, transition] }
            error: { type: string }

    OrderStatus:
      type: object
      required:
        - order_number
        - payment_status
        - fulfillment_state
        - duplicate_payment
        - project_name
        - total_cents
        - currency
        - carrier
        - tracking_number
      properties:
        order_number:
          type: string
          description: Customer-facing number (`#3F2A9C1B`), matching the emails.
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
        duplicate_payment:
          type: boolean
          description: >
            True when this payment duplicated an order that already existed
            for the design (two checkouts completed). Nothing prints for it;
            it is refunded.
        project_name: { type: string }
        total_cents: { type: [integer, "null"], format: int64 }
        currency: { type: string }
        carrier: { type: [string, "null"] }
        tracking_number: { type: [string, "null"] }
    AdminOrderSummary:
      type: object
      required: [id, order_number, created_at, updated_at, payment_status, fulfillment_state, source, project_id, project_name, email, total_cents, refunded_cents, currency, tracking_number, duplicate_of]
      properties:
        id: { type: string, format: uuid }
        order_number:
          type: string
          description: >
            What the customer sees — `#0DA89668`, the first eight characters
            of the id, and the same string the emails, the post-checkout
            page and the iOS app quote. The id is the capability; this is
            the thing a customer reads out over support.

        created_at: { type: string }
        updated_at: { type: string }
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
        source: { type: string }
        project_id: { type: string }
        project_name: { type: string }
        email: { type: [string, "null"] }
        total_cents: { type: [integer, "null"], format: int64 }
        refunded_cents: { type: integer, format: int64 }
        currency: { type: string }
        tracking_number: { type: [string, "null"] }
        duplicate_of: { type: [string, "null"], format: uuid }
    AdminOrder:
      type: object
      description: >
        The full order row as stored, plus `order_number` — the customer-facing
        string, which is derived rather than stored.
      required:
        - id
        - order_number
        - created_at
        - updated_at
        - payment_status
        - fulfillment_state
        - source
        - project_id
        - project_name
        - email
        - stytch_user_id
        - receipt_url
        - stripe_checkout_session_id
        - stripe_payment_intent_id
        - currency
        - subtotal_cents
        - shipping_cents
        - tax_cents
        - discount_cents
        - total_cents
        - refunded_cents
        - promo_code
        - ship_name
        - ship_line1
        - ship_line2
        - ship_city
        - ship_state
        - ship_postal_code
        - ship_country
        - carrier
        - tracking_number
        - shipped_at
        - duplicate_of
        - approved_layout
      properties:
        id: { type: string, format: uuid }
        order_number:
          type: string
          description: The customer-facing number, e.g. `#0DA89668`.
        created_at: { type: string }
        updated_at: { type: string }
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        fulfillment_state: { $ref: "#/components/schemas/FulfillmentState" }
        source: { type: string }
        project_id: { type: string }
        project_name: { type: string }
        email: { type: [string, "null"] }
        stytch_user_id:
          type: [string, "null"]
          description: The customer's Stytch id when they were signed in at checkout.
        receipt_url: { type: [string, "null"] }
        stripe_checkout_session_id: { type: [string, "null"] }
        stripe_payment_intent_id: { type: [string, "null"] }
        currency: { type: string }
        subtotal_cents: { type: [integer, "null"], format: int64 }
        shipping_cents: { type: [integer, "null"], format: int64 }
        tax_cents: { type: [integer, "null"], format: int64 }
        discount_cents: { type: [integer, "null"], format: int64 }
        total_cents: { type: [integer, "null"], format: int64 }
        refunded_cents:
          type: integer
          format: int64
          description: Cumulative amount returned via Stripe; 0 until a refund.
        promo_code: { type: [string, "null"] }
        ship_name: { type: [string, "null"] }
        ship_line1: { type: [string, "null"] }
        ship_line2: { type: [string, "null"] }
        ship_city: { type: [string, "null"] }
        ship_state: { type: [string, "null"] }
        ship_postal_code: { type: [string, "null"] }
        ship_country: { type: [string, "null"] }
        carrier: { type: [string, "null"] }
        tracking_number: { type: [string, "null"] }
        shipped_at: { type: [string, "null"] }
        duplicate_of:
          type: [string, "null"]
          format: uuid
          description: >
            Set when this order's payment arrived for a project that already
            had a live order: the id of the order it lost to. Money taken,
            not honoured — `paid`, on the worklist to be refunded, never
            printed, never blocking a checkout (BRC-76).
        approved_layout:
          description: >
            What approval saw — sheets (id, medium, pack, print master) and
            per-line tally — while the order is `approved`; null otherwise.
            Submit compares the live layout to it (BRC-77).
    AdminOrderItem:
      type: object
      required:
        [id, order_id, position, title, description, quantity, unit_amount_cents, image_url]
      properties:
        id: { type: string, format: uuid }
        order_id: { type: string, format: uuid }
        position: { type: integer }
        title:
          type: string
          description: Customer-facing summary, e.g. `6 stickers · 2.9″ each`.
        description:
          type: string
          description: >
            What was bought, in human terms — medium and pack, e.g.
            `5.5 x 5.5" (140x140mm) · 2-pack`. Gooten's variant identifier is
            never stored; it is derived from (medium, pack) at submit time.
        quantity: { type: integer }
        unit_amount_cents: { type: integer, format: int64 }
        image_url:
          type: [string, "null"]
          description: The line's cut-out, captured at checkout.

    AdminOrderEvent:
      type: object
      required: [id, order_id, created_at, kind, actor, from_state, to_state, detail]
      properties:
        id: { type: string, format: uuid }
        order_id: { type: string, format: uuid }
        created_at: { type: string }
        kind:
          type: string
          description: >
            `created`, `payment_changed`, `fulfillment_changed` (the two
            tracks' transitions; `from_state`/`to_state` name the edge),
            `refund_recorded`, `refund_failed`, plus annotations such as
            `checkout_session_created`, `awaiting_async_payment`,
            `async_payment_failed`, `supersede_failed`, `followup_failed`,
            `email_sent`, `duplicate_payment` (on the live order),
            `duplicate_payment_received` (on the duplicate).
        actor: { type: string }
        from_state: { type: [string, "null"] }
        to_state: { type: [string, "null"] }
        detail: {}
    CheckoutSessionRequest:
      type: object
      required: [project_id]
      properties:
        project_id:
          type: string
          description: The draft project holding the order's sticker lines.
        email:
          type: [string, "null"]
          description: >
            Prefill for the Stripe page when the storefront knows it
            (logged-in customers). Stripe validates it; omit when unknown.
    CheckoutSessionResponse:
      type: object
      required: [url, order_id]
      properties:
        url:
          type: string
          description: Stripe's hosted Checkout page — redirect here.
        order_id:
          type: string
          format: uuid
          description: The pending commerce order created for this attempt.
    CheckoutErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum:
            - missing_project_id
            - unknown_shipping_code
            - project_not_found
            - project_already_ordered
            - no_quotable_lines
            - catalog_drift
            - storage_failure
            - sticker_api_unavailable
            - stripe_unavailable
    ShipTo:
      type: object
      required: [line1, city, state, postal_code, country]
      properties:
        name: { type: [string, "null"] }
        line1: { type: string }
        line2: { type: [string, "null"] }
        city: { type: string }
        state: { type: string }
        postal_code: { type: string }
        country:
          type: string
          description: ISO 3166-1 alpha-2; `US` is the only shippable value today.
    MobileIntentRequest:
      type: object
      required: [project_id, email, shipping_code, ship_to]
      properties:
        project_id:
          type: string
          description: The draft project created via `/drafts` at checkout open.
        email:
          type: string
          description: Typed once at checkout; required on mobile.
        shipping_code:
          type: string
          enum: [standard, priority]
        ship_to:
          $ref: "#/components/schemas/ShipTo"
    AmountBreakdown:
      type: object
      required: [subtotal_cents, shipping_cents, tax_cents, total_cents]
      properties:
        subtotal_cents: { type: integer, format: int64 }
        shipping_cents: { type: integer, format: int64 }
        tax_cents:
          type: integer
          format: int64
          description: Zero until Stripe Tax is wired to this path (BRC-30).
        total_cents: { type: integer, format: int64 }
    MobileIntentResponse:
      type: object
      required: [client_secret, publishable_key, order_id, amounts]
      properties:
        client_secret:
          type: string
          description: PaymentIntent client secret for PaymentSheet.
        publishable_key:
          type: string
          description: Stripe publishable key for this environment.
        order_id:
          type: string
          format: uuid
        amounts:
          $ref: "#/components/schemas/AmountBreakdown"
    PriceEntry:
      type: object
      required: [medium_title, pack, unit_amount_cents]
      properties:
        medium_title:
          type: string
          description: >
            `CanvasPreset.title` exactly as the sticker API's quotes carry it
            (`layout.medium_title`), e.g. `3 x 4" (76x102mm)`.
        pack:
          type: integer
          format: int32
          description: Sheets in the pack — one of the Gooten pack sizes we sell.
        unit_amount_cents:
          type: integer
          format: int64
          description: Price of that pack of sheets, in cents.

    ShippingRate:
      type: object
      required: [code, display_name, amount_cents]
      properties:
        code:
          type: string
          description: The `shipping_code` a checkout request may name (`standard`, `priority`).
        display_name:
          type: string
        amount_cents:
          type: integer
          format: int64

    PriceList:
      type: object
      required: [currency, prices, shipping]
      properties:
        currency:
          type: string
          description: ISO 4217, lower-case (`usd`).
        prices:
          type: array
          description: Every (medium × pack) we sell — media in catalog order, packs ascending.
          items:
            $ref: "#/components/schemas/PriceEntry"
        shipping:
          type: array
          description: Both flat rates, cheapest first.
          items:
            $ref: "#/components/schemas/ShippingRate"

    Health:
      type: object
      required: [status, db]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        db:
          type: string
          enum: [ok, unreachable]
