openapi: 3.1.0
info:
  title: Hoard API
  description: >
    The Hoard API powers the Hoard Agent — a background program that syncs
    trading card seller inventory between TCGplayer and the Hoard server. The
    API is also available for custom integrations.

    Base URL: https://www.tryhoard.com/api/v1

    All endpoints require Bearer token authentication. Get your API key from
    Settings at tryhoard.com (Pro plan required).
  version: "1"
  contact:
    email: support@tryhoard.com
  license:
    name: Proprietary

servers:
  - url: https://www.tryhoard.com/api/v1
    description: Production

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Bearer token from your Hoard API key. Include as:
        `Authorization: Bearer YOUR_API_KEY`

  schemas:
    Error:
      type: object
      description: Standard error response returned by all endpoints on failure.
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
          example: "Invalid or missing API key"
        code:
          type: string
          description: >
            Machine-readable error code. Use this for programmatic error handling.
          example: "unauthorized"
      required:
        - error
        - code

    InternalError:
      type: object
      description: >
        Returned for unhandled 4xx (404/422) and 5xx exceptions when the client sends
        `Accept: application/json`. Routed through `ErrorsController` via
        `config.exceptions_app`. Browsers hitting the same path receive a styled HTML
        page that displays the same `request_id` as a quotable support reference.
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.
          example: "Something went wrong"
        code:
          type: string
          description: >
            Machine-readable error code. One of `not_found`, `unprocessable_content`,
            or `internal_server_error`.
          example: "internal_server_error"
        status:
          type: integer
          description: HTTP status code (matches the response status line).
          example: 500
        request_id:
          type: string
          description: >
            `ActionDispatch::RequestId` value for this request. Quote this when contacting
            support — Rails logs in production tag every line with this ID, so we can
            pivot from your error report to the server-side exception in one search.
          example: "a3f2c0d4-1b8e-4567-a9f1-3c2d8e6b4f5a"
      required:
        - error
        - code
        - status
        - request_id

    ProductLine:
      type: object
      description: A product line (game) enabled for the account.
      properties:
        name:
          type: string
          description: Internal product line name.
          example: "magic"
        category_id:
          type: integer
          description: TCGplayer category ID for this product line.
          example: 1
      required:
        - name
        - category_id

    HeartbeatResponse:
      type: object
      description: Agent keepalive response including update info and configured product lines.
      properties:
        update_available:
          type: boolean
          description: Whether a newer agent version is available.
          example: false
        force_update:
          type: boolean
          description: Whether the agent must update before continuing (kill-switched version).
          example: false
        latest_version:
          type: string
          description: Latest available agent version string. Only present when update_available is true.
          example: "0.4.10"
        download_url:
          type: string
          description: URL to download the latest agent binary. Only present when update_available is true.
          example: "https://releases.tryhoard.com/hoard-agent-0.4.10-darwin-arm64"
        signature:
          type: string
          description: Ed25519 signature for verifying the download. Only present when update_available is true.
        sha256:
          type: string
          description: SHA-256 checksum for verifying the download. Only present when update_available is true.
        product_lines:
          type: array
          description: Product lines enabled for this account. The agent uses these to know which games to sync.
          items:
            $ref: '#/components/schemas/ProductLine'
        has_pending_tasks:
          type: boolean
          description: Whether the server has backfill tasks waiting for this agent.
          example: false
        sync_mode:
          type: string
          enum: ["full", "read_only"]
          description: >
            The account's sync mode. `read_only` means the agent should save
            the repriced CSV locally but not upload it to TCGplayer.
            `full` means the agent uploads after repricing.
          example: "read_only"
        upload_approved:
          type: boolean
          description: >
            Whether the seller has approved a one-time upload ("Push Once").
            When true, the agent should upload the most recent prices CSV
            from the exports directory. The agent clears this flag via
            DELETE /sync/approve_upload after a successful upload.
          example: false
      required:
        - update_available
        - force_update
        - product_lines
        - sync_mode
        - upload_approved

    SyncPendingResponse:
      type: object
      description: Server decision on whether the agent should run a sync cycle.
      properties:
        action:
          type: string
          enum: ["sync", "wait"]
          description: >
            `sync` — run a sync cycle now.
            `wait` — do nothing, poll again later.
          example: "wait"
        reason:
          type: string
          enum:
            - manual_request
            - scheduled
            - no_sync_needed
            - in_progress
            - auto_sync_paused
          description: Why the server made this decision.
          example: "no_sync_needed"
        sync_requested:
          type: boolean
          description: Whether a sync was explicitly requested by the user.
          example: false
        first_sync:
          type: boolean
          description: >
            When true, the agent should perform a full historical backfill
            (orders from 2020-01-01 instead of the last 90 days). Set when
            the user has never synced or has requested a full history import.
            Only present when action is "sync".
          example: false
        kind:
          type: string
          enum: ["full", "pricing", "orders", "refunds", "feedback", "sales", "account"]
          description: >
            Which subset of the sync cycle the customer requested. Defaults
            to `full`. Only present when action is `sync`.
          example: "full"
        plan:
          type: object
          description: >
            Dependency-aware sync plan (bd ahg-ggha). Only emitted when the
            user has opted into dependency-aware syncing AND the agent
            advertised its step registry via the `capabilities` query param.
            Steps within their freshness window are listed under `skipped`
            and should NOT be re-run by the agent.
          properties:
            kind:
              type: string
            steps:
              type: array
              items:
                type: string
            skipped:
              type: array
              items:
                type: object
                properties:
                  name: { type: string }
                  reason: { type: string }
                  last_succeeded_at: { type: string, format: date-time, nullable: true }
                  freshness_seconds: { type: integer }
            error:
              type: string
              nullable: true
              description: Non-null when the planner refused to plan (e.g. capability mismatch).
      required:
        - action
        - reason
        - sync_requested

    StatusResponse:
      type: object
      description: Current sync state for the account.
      properties:
        sync_in_progress:
          type: boolean
          description: Whether a sync is currently running.
          example: false
        last_sync_at:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 timestamp of the last completed sync, or null if never synced.
          example: "2026-04-19T14:30:00Z"
        agent_last_seen_at:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 timestamp of the last agent heartbeat, or null if never seen.
          example: "2026-04-20T08:00:00Z"
        sync_mode:
          type: string
          enum: ["full", "read_only"]
          description: >
            The account's sync mode. `read_only` (Review Mode) means the
            agent saves repriced CSVs locally without uploading. `full`
            means automatic upload after repricing.
          example: "read_only"
      required:
        - sync_in_progress
        - last_sync_at
        - agent_last_seen_at
        - sync_mode

    SyncCooldownResponse:
      type: object
      description: >
        Returned with HTTP 402 when `credits_metering_enabled` is on and
        the user is inside the post-sync cooldown window.
      properties:
        status:
          type: string
          enum: ["cooldown"]
          description: Always `"cooldown"`.
          example: "cooldown"
        cooldown_remaining_seconds:
          type: integer
          description: Whole seconds remaining until the cooldown window ends.
          example: 878
        balance:
          type: integer
          description: Current credit balance — useful for client-side UI hints.
          example: 4
        top_up_path:
          type: string
          description: Relative path to the top-up flow on the marketing site.
          example: "/billing/credit_packs"
      required:
        - status
        - cooldown_remaining_seconds
        - balance
        - top_up_path

    SyncSkipWaitSuccessResponse:
      type: object
      description: >
        Skip-wait happy paths: `charged`, `replayed`, `no_charge_needed`,
        and `sync_in_flight`. For `sync_in_flight` only `status` is
        guaranteed; the other fields are present on the other three
        statuses.
      properties:
        status:
          type: string
          enum: ["charged", "replayed", "no_charge_needed", "sync_in_flight"]
          description: >
            `charged` — credit deducted, sync requested. `replayed` —
            same idempotency key as a prior call; original transaction
            returned with no second charge. `no_charge_needed` — user
            is already past cooldown, no charge taken. `sync_in_flight`
            — a sync is already running; from the caller's POV the
            effect is achieved.
          example: "charged"
        balance:
          type: integer
          description: >
            Current credit balance. Present for charged / replayed /
            no_charge_needed. Omitted for sync_in_flight.
          example: 4
        cooldown_remaining_seconds:
          type: integer
          description: >
            Whole seconds remaining in the cooldown window. Present for
            charged / replayed / no_charge_needed. Omitted for
            sync_in_flight.
          example: 852
        transaction_id:
          type: integer
          nullable: true
          description: >
            Credit-transaction row ID. Present and non-null for
            `charged` and `replayed`. Null/omitted for
            `no_charge_needed` and `sync_in_flight`.
          example: 18
      required:
        - status

    SyncSkipWaitInsufficientResponse:
      type: object
      description: Returned with HTTP 402 when the user has zero credits.
      properties:
        status:
          type: string
          enum: ["insufficient_balance"]
          description: Always `"insufficient_balance"`.
          example: "insufficient_balance"
        balance:
          type: integer
          description: Always 0 in this branch.
          example: 0
        top_up_path:
          type: string
          description: Relative path to the top-up flow.
          example: "/billing/credit_packs"
      required:
        - status
        - balance
        - top_up_path

    SyncSkipWaitMutexResponse:
      type: object
      description: >
        Returned with HTTP 409 when the price-mutation mutex is held by a
        concurrent sync, rollback, or mass reprice.
      properties:
        status:
          type: string
          enum: ["mutex_held"]
          description: Always `"mutex_held"`.
          example: "mutex_held"
        retry_after_seconds:
          type: integer
          description: Suggested seconds to wait before retrying.
          example: 30
      required:
        - status
        - retry_after_seconds

    OrderItem:
      type: object
      description: A single order from TCGplayer.
      properties:
        order_number:
          type: string
          description: TCGplayer order number.
          example: "ORD-12345"
        buyer_name:
          type: string
          description: Buyer's name from the order.
          example: "John Doe"
        order_date:
          type: string
          description: Order date string as parsed from TCGplayer CSV (e.g., "3/27/2026").
          example: "3/27/2026"
        status:
          type: string
          description: Order status from TCGplayer.
          example: "Completed"
        product_amt:
          type: number
          format: float
          description: Product subtotal in USD.
          example: 24.99
        shipping_amt:
          type: number
          format: float
          description: Shipping amount in USD.
          example: 0.99
        total_amt:
          type: number
          format: float
          description: Total order amount in USD.
          example: 25.98
      required:
        - order_number

    OrderRefundItem:
      type: object
      description: A refund row from TCGplayer's order export.
      properties:
        order_number:
          type: string
          description: TCGplayer order number being refunded.
          example: "ORD-12345"
        buyer_name:
          type: string
          description: Buyer's name from the order.
          example: "John Doe"
        product_amt:
          type: number
          format: float
          description: Refunded product amount in USD.
          example: 24.99
        shipping_amt:
          type: number
          format: float
          description: Refunded shipping amount in USD.
          example: 0.99
        total_amt:
          type: number
          format: float
          description: Total refund amount in USD.
          example: 25.98
        order_date:
          type: string
          description: Order date string as parsed from TCGplayer CSV.
          example: "3/27/2026"
        status:
          type: string
          description: Order status (typically "Refunded").
          example: "Refunded"
        refund_type:
          type: string
          description: full or partial.
          example: "full"
        refund_origin:
          type: string
          description: Source of the refund (e.g., tcgplayer, paypal).
          example: "tcgplayer"
      required:
        - order_number

    OrderLineItem:
      type: object
      description: A single line item belonging to an order.
      properties:
        order_number:
          type: string
          description: TCGplayer order number this item belongs to.
          example: "ORD-12345"
        product_name:
          type: string
          description: Product name from the order export.
          example: "Lightning Bolt"
        product_line:
          type: string
          description: TCGplayer product line (Magic, Pokemon, etc).
          example: "Magic"
        condition:
          type: string
          description: Card condition (Near Mint, Lightly Played, etc).
          example: "Near Mint"
        set_name:
          type: string
          description: Set the card belongs to.
          example: "Alpha"
        rarity:
          type: string
          description: Card rarity code.
          example: "C"
        quantity:
          type: integer
          description: Number of copies in the order.
          example: 2
        sku_id:
          type: integer
          format: int64
          description: TCGplayer SKU ID. Multiple sku-less rows are allowed per order.
          example: 12345
        main_photo_url:
          type: string
          description: TCGplayer main photo URL for the SKU.
          example: "https://example.com/bolt.jpg"
      required:
        - order_number

    OrderShippingItem:
      type: object
      description: Shipping enrichment for an existing order.
      properties:
        order_number:
          type: string
          description: TCGplayer order number to enrich. Must match an existing order.
          example: "ORD-12345"
        shipping_method:
          type: string
          description: Carrier/service used for shipping.
          example: "USPS First Class"
        city:
          type: string
          description: Buyer city.
          example: "Portland"
        state:
          type: string
          description: Buyer state code.
          example: "OR"
        postal_code:
          type: string
          description: Buyer postal code.
          example: "97201"
        tracking_number:
          type: string
          description: Carrier tracking number.
          example: "9400111899223100001234"
        item_count:
          type: integer
          nullable: true
          description: Number of items in the shipment.
          example: 3
        product_weight:
          type: number
          format: float
          nullable: true
          description: Total product weight (units per TCGplayer export).
          example: 0.5
      required:
        - order_number

    OrderFeedbackItem:
      type: object
      description: Seller feedback rating for a single order.
      properties:
        order_number:
          type: string
          description: TCGplayer order number to attach feedback to.
          example: "ORD-12345"
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: Seller rating from 1 to 5.
          example: 5
        comment:
          type: string
          nullable: true
          description: Optional buyer comment.
          example: "Fast shipping, great packaging!"
      required:
        - order_number
        - rating

    SalesReportData:
      type: object
      description: Aggregated sales metrics for a date range.
      properties:
        gross_sales:
          type: number
          format: float
          description: Total gross sales in USD.
          example: 1234.56
        order_count:
          type: integer
          description: Number of orders in the period.
          example: 42
        product_amount:
          type: number
          format: float
          description: Product revenue in USD.
          example: 1100.00
        total_fees:
          type: number
          format: float
          description: Total marketplace fees in USD.
          example: 134.56
        shipping_amount:
          type: number
          format: float
          description: Total shipping collected in USD.
          example: 41.58
        total_refund_amount:
          type: number
          format: float
          description: Total refund amount in USD.
          example: 0.00
        refunded_orders:
          type: integer
          description: Number of refunded orders.
          example: 0
        refunded_fees:
          type: number
          format: float
          description: Fees refunded in USD.
          example: 0.00
        adjustments:
          type: number
          format: float
          description: Marketplace adjustments in USD.
          example: 0.00
        net_sales_minus_fees:
          type: number
          format: float
          description: Net sales after fees in USD.
          example: 965.44
        net_sales:
          type: number
          format: float
          description: Net sales in USD.
          example: 1100.00

    SyncLogStep:
      type: object
      description: A single step in a sync cycle log.
      properties:
        name:
          type: string
          description: Step name (e.g., "login", "export_inventory").
          example: "export_inventory"
        status:
          type: string
          enum: ["success", "failed", "skipped"]
          description: Outcome of this step.
          example: "success"
        duration_ms:
          type: integer
          description: How long the step took in milliseconds.
          example: 8500
        error:
          type: string
          nullable: true
          description: Error message if the step failed.
          example: "timeout waiting for download"
      required:
        - name
        - status

    DesktopUpdateResponse:
      type: object
      description: Desktop wrapper update check response.
      properties:
        update_available:
          type: boolean
          description: Whether a newer desktop version is available.
          example: false
        force_update:
          type: boolean
          description: Whether the user must update before the desktop app will continue.
          example: false
        latest_version:
          type: string
          description: Latest available desktop version. Only present when update_available is true.
          example: "0.2.1"
        download_url:
          type: string
          description: URL to download the latest desktop binary. Only present when update_available is true.
        signature:
          type: string
          description: Ed25519 signature for verifying the download. Only present when update_available is true.
        sha256:
          type: string
          description: SHA-256 checksum for verifying the download. Only present when update_available is true.
      required:
        - update_available
        - force_update

paths:
  /heartbeat:
    post:
      operationId: postHeartbeat
      summary: Agent keepalive
      description: >
        Called by the Hoard Agent on a regular interval (every 30 seconds) to
        signal it is alive. The server records the last-seen timestamp, clears
        any stale sync locks, and returns update info plus the list of product
        lines configured for the account.

        The request body is optional. If provided, it should include the agent
        version and platform so the server can determine if an update is
        available.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: string
                  description: Current agent version string.
                  example: "0.4.9"
                platform:
                  type: string
                  description: >
                    Agent platform identifier used to select the correct binary
                    for updates (e.g., `darwin/arm64`, `windows/amd64`,
                    `linux/amd64`).
                  example: "darwin/arm64"
      responses:
        "200":
          description: Heartbeat recorded. Returns update status and product lines.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HeartbeatResponse'
              example:
                update_available: false
                force_update: false
                product_lines:
                  - name: magic
                    category_id: 1
                  - name: pokemon
                    category_id: 2
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the rate limit window resets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/pending:
    get:
      operationId: getSyncPending
      summary: Check if a sync should run
      description: >
        The agent calls this every 30 seconds to ask the server "should I sync
        now?" The server is the single decision-maker. Returns either
        `action: "sync"` (run a cycle) or `action: "wait"` (poll again later).

        The server automatically clears stale sync locks (older than 5 minutes)
        on this call.
      responses:
        "200":
          description: Server decision on whether to sync.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncPendingResponse'
              examples:
                wait:
                  summary: No sync needed
                  value:
                    action: "wait"
                    reason: "no_sync_needed"
                    sync_requested: false
                sync:
                  summary: Manual sync requested
                  value:
                    action: "sync"
                    reason: "manual_request"
                    sync_requested: true
                    first_sync: false
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync:
    post:
      operationId: postSync
      summary: Upload inventory CSV
      description: >
        Upload a gzipped inventory CSV exported from TCGplayer. The server
        queues it for async processing — card matching, price snapshot, and
        repricing target calculation all happen in the background.

        The request body must be the raw gzip-compressed CSV bytes. The server
        accepts up to 20 MB (compressed). If the `Content-Type` is not
        `application/gzip`, the server will attempt to decompress anyway and
        fall back to treating the body as plain CSV.

        Include `X-Product-Line` to specify which game's inventory this is.
        The value must match one of the product lines configured for the
        account (returned by `/heartbeat`). If omitted the server uses the
        account default.
      parameters:
        - in: header
          name: X-Product-Line
          required: false
          schema:
            type: string
            example: "magic"
          description: >
            Product line this inventory belongs to (e.g., `magic`, `pokemon`,
            `yugioh`). Must be configured on the account.
      requestBody:
        required: true
        content:
          application/gzip:
            schema:
              type: string
              format: binary
              description: Gzip-compressed TCGplayer inventory CSV.
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: Raw CSV or gzip-compressed CSV bytes.
      responses:
        "202":
          description: CSV accepted and queued for processing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "accepted"
              example:
                status: "accepted"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "413":
          description: >
            Wire-bytes overflow (`payload_too_large`, > 10 MB) or gzip-bomb
            defense (`decompressed_too_large`, > 50 MB after decompression).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                payload_too_large:
                  value:
                    error: "Payload too large"
                    code: "payload_too_large"
                decompressed_too_large:
                  value:
                    error: "Decompressed payload too large"
                    code: "decompressed_too_large"
        "422":
          description: >
            CSV validation failed, invalid product line, or invalid gzip body
            (when Content-Type is gzip).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_csv:
                  value:
                    error: "Invalid CSV: empty"
                    code: "invalid_csv"
                invalid_gzip:
                  value:
                    error: "Body is not valid gzip"
                    code: "invalid_gzip"
                invalid_product_line:
                  value:
                    error: "Product line 'yugioh' is not configured for this user"
                    code: "invalid_product_line"
                csv_processing_failed:
                  value:
                    error: "CSV processing failed"
                    code: "csv_processing_failed"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/cancel:
    post:
      operationId: postSyncCancel
      summary: Release sync lock
      description: >
        Release the sync-in-progress lock if the agent encounters an error
        mid-sync and cannot finish. This allows the next sync cycle to start
        without waiting for the 5-minute automatic lock expiry.

        The server auto-clears stale locks after 5 minutes, so this is a
        courtesy call. If no sync is in progress a 409 is returned.
      responses:
        "200":
          description: Sync lock released.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "cancelled"
              example:
                status: "cancelled"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "409":
          description: No sync is currently in progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "No sync is currently in progress"
                code: "no_sync_in_progress"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/request:
    post:
      operationId: postSyncRequest
      summary: Request a manual sync ("Sync Now")
      description: >
        Marks the account as having an explicit user-requested sync. The
        next agent `/sync/pending` poll returns `action: "sync"`. The agent
        then runs a full sync cycle.

        When the per-account `credits_metering_enabled` flag is **off**
        (the default for every account), this endpoint always returns 200
        regardless of how recently a sync ran — behavior is unchanged from
        before the credits-metering feature shipped.

        When `credits_metering_enabled` is **on** and the user is still
        within the cooldown window from their last successful sync, the
        endpoint returns 402 with `status: "cooldown"` and the seconds
        remaining. The user can either wait or call
        `POST /sync/skip_wait` to spend a credit and skip the wait.

        Accepts an optional `kind` body so dashboard buttons can scope the
        sync ("Reprice Now" sends `pricing`, "Re-scan refunds" sends
        `refunds`, etc.) — see bd ahg-ggha. Omitting `kind` defaults to a
        full sync. Unknown kinds fall back to `full` rather than erroring.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum: ["full", "pricing", "orders", "refunds", "feedback", "sales", "account"]
      responses:
        "200":
          description: Sync requested. The agent will pick it up on the next poll.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "requested"
                  kind:
                    type: string
                    example: "full"
                required:
                  - status
              example:
                status: "requested"
                kind: "full"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "402":
          description: >
            Credits metering is enabled for this account and the user is
            inside the post-sync cooldown window. The user must wait the
            remaining seconds or call `POST /sync/skip_wait` to spend a
            credit and bypass the wait.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncCooldownResponse'
              example:
                status: "cooldown"
                cooldown_remaining_seconds: 878
                balance: 4
                top_up_path: "/billing/credit_packs"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/skip_wait:
    post:
      operationId: postSyncSkipWait
      summary: Spend 1 credit to bypass the manual-sync cooldown
      description: >
        Charges one credit from the user's `UserCreditBalance`, sets a new
        manual sync request, and returns the updated balance and remaining
        cooldown. Wraps the `Credits::SkipWaitCharge` service which uses a
        FOR UPDATE row lock plus a reservation pattern so concurrent calls
        with the same `X-Idempotency-Key` cannot double-charge.

        Idempotency: callers MUST send `X-Idempotency-Key` (a UUID
        recommended). Replays of the same key return `status: "replayed"`
        with the original `transaction_id` and no second charge.

        This endpoint is independent of `credits_metering_enabled` — the
        user calling it IS the explicit opt-in. A flag-OFF user with a
        balance can call it just fine; the gate is the balance, not the
        flag.
      parameters:
        - in: header
          name: X-Idempotency-Key
          required: true
          schema:
            type: string
            example: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
          description: >
            Caller-supplied idempotency key (UUID recommended). Replaying
            the same key returns the original transaction without a second
            charge.
      responses:
        "200":
          description: >
            Charge succeeded, was replayed, was not needed (already past
            cooldown), or sync is already in flight. All four are happy
            paths from the caller's POV.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncSkipWaitSuccessResponse'
              examples:
                charged:
                  summary: First call — credit deducted, sync requested
                  value:
                    status: "charged"
                    balance: 4
                    cooldown_remaining_seconds: 852
                    transaction_id: 18
                replayed:
                  summary: Same idempotency key replayed — same transaction returned
                  value:
                    status: "replayed"
                    balance: 4
                    cooldown_remaining_seconds: 852
                    transaction_id: 18
                no_charge_needed:
                  summary: User is already past cooldown — no charge needed
                  value:
                    status: "no_charge_needed"
                    balance: 5
                    cooldown_remaining_seconds: 0
                sync_in_flight:
                  summary: A sync is already in flight; the user got their effect
                  value:
                    status: "sync_in_flight"
        "400":
          description: Missing or blank `X-Idempotency-Key` header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "X-Idempotency-Key header is required"
                code: "idempotency_key_required"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "402":
          description: Insufficient credits. Direct the user to the top-up flow.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncSkipWaitInsufficientResponse'
              example:
                status: "insufficient_balance"
                balance: 0
                top_up_path: "/billing/credit_packs"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "409":
          description: >
            Price-mutation mutex is held (a sync, rollback, or mass reprice
            is currently writing prices). Retry after `retry_after_seconds`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SyncSkipWaitMutexResponse'
              example:
                status: "mutex_held"
                retry_after_seconds: 30
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/approve_upload:
    post:
      operationId: postApproveUpload
      summary: Approve a one-time price upload (Push Once / Always Push)
      description: >
        Sets `upload_approved_at` so the agent knows to upload the most recent
        prices CSV on its next tick. With `?always=true`, also switches
        `sync_mode` to `full` (Always Push).

        Returns 409 if a sync, rollback, or mass reprice is currently in
        progress (`price_mutation_in_progress?`).
      parameters:
        - name: always
          in: query
          required: false
          schema:
            type: boolean
          description: >
            When true, also switches sync_mode from read_only to full
            (permanent). This is the "Always Push" action.
      responses:
        "200":
          description: Upload approved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "approved"
                  sync_mode:
                    type: string
                    example: "read_only"
              example:
                status: "approved"
                sync_mode: "read_only"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "409":
          description: A price mutation (sync, rollback, or reprice) is in progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "A sync, rollback, or reprice is in progress. Try again in a moment."
    delete:
      operationId: deleteApproveUpload
      summary: Clear upload approval (agent calls after successful upload)
      description: >
        Clears `upload_approved_at` after the agent has successfully uploaded
        the prices CSV. Prevents re-upload on the next heartbeat tick.
      responses:
        "200":
          description: Upload approval cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "cleared"
              example:
                status: "cleared"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"

  /export/price-updates:
    get:
      operationId: getExportPriceUpdates
      summary: Download repricing CSV
      description: >
        Download a CSV of price changes to import back to TCGplayer. The CSV
        format matches what TCGplayer's price import tool expects.

        If a price rollback is pending (initiated from the dashboard), the
        rollback CSV is served instead of the normal repricing targets. The
        agent cannot tell the difference — both use the same CSV format.

        Returns an empty CSV string if no repricing targets are available.

        Conditional GET: when no rollback or Quick Add work is pending, the
        response carries a weak ETag derived from the underpriced-cards
        fingerprint. Clients that send `If-None-Match: <last-etag>` will
        receive `304 Not Modified` if nothing has changed since the last
        pull, letting the agent skip CSV serialization entirely. Rollback
        and Quick Add paths always return `200` because they carry
        side-effectful state transitions.
      responses:
        "200":
          description: >
            CSV file with repricing targets. Empty string if nothing to update.
          headers:
            ETag:
              schema:
                type: string
              description: >
                Weak ETag fingerprint of the underpriced-cards set. Only
                present on the pure underpriced path; absent (or default
                middleware-generated) on rollback / Quick Add responses.
          content:
            text/csv:
              schema:
                type: string
                format: binary
                description: TCGplayer-compatible price import CSV.
        "304":
          description: >
            No changes since the last pull. Sent only when `If-None-Match`
            matches the current ETag and no rollback or Quick Add work is
            pending. Body is empty.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /status:
    get:
      operationId: getStatus
      summary: Sync status
      description: >
        Returns the current sync state for the account. Accessible via both
        Bearer token (agent) and browser session (dashboard).
      responses:
        "200":
          description: Current sync status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusResponse'
              example:
                sync_in_progress: false
                last_sync_at: "2026-04-19T14:30:00Z"
                agent_last_seen_at: "2026-04-20T08:00:00Z"
        "401":
          description: Invalid or missing API key and no session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Unauthorized"
                code: "unauthorized"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /orders:
    post:
      operationId: postOrders
      summary: Upload order data
      description: >
        Upload parsed order data from TCGplayer's order export. The request body
        is shape-validated synchronously, then queued for upsert in a background
        job — the response is `202 Accepted` and the actual database write
        happens asynchronously. Orders are upserted by `(user_id, order_number)`
        so re-uploading the same orders is safe. The job triggers customer and
        sales aggregation after the upsert completes. When an active Pull session
        exists, refreshed orders are also checked against pull eligibility so the
        tablet queue can warn about orders that were cancelled, shipped, or
        otherwise drifted during a mid-day refresh.

        Both raw `application/json` and gzip-compressed JSON are accepted. For
        large batches (the first full-history upload, typically), gzip is strongly
        preferred — set `Content-Type: application/gzip` and gzip the JSON body.
        Smaller payloads can use `application/json` directly. The server
        transparently decompresses gzip and falls back to raw bytes when the body
        is not gzip-encoded.

        The first upload should contain full history (from January 2020).
        Subsequent uploads typically cover the last 90 days.

        Order upload is non-fatal in the sync cycle: if it fails, the
        inventory sync still completes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/OrderItem'
              minItems: 1
            example:
              - order_number: "ORD-12345"
                buyer_name: "John Doe"
                order_date: "3/27/2026"
                status: "Completed"
                product_amt: 24.99
                shipping_amt: 0.99
                total_amt: 25.98
          application/gzip:
            schema:
              type: string
              format: binary
              description: >
                A gzip-compressed JSON array of orders. Same schema as the
                `application/json` body, just compressed.
      responses:
        "202":
          description: >
            Orders accepted for asynchronous import. The shape was validated
            synchronously; the upsert runs in a background job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "accepted"
                  count:
                    type: integer
                    description: Number of orders in the request.
                    example: 1
              example:
                status: "accepted"
                count: 1
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: >
            Invalid JSON, invalid gzip body (when Content-Type is gzip), empty
            array, non-array body, or rows missing `order_number`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                invalid_gzip:
                  value:
                    error: "Body is not valid gzip"
                    code: "invalid_gzip"
                invalid_payload:
                  value:
                    error: "Expected an array of orders"
                    code: "invalid_payload"
                empty_payload:
                  value:
                    error: "No orders"
                    code: "empty_payload"
        "413":
          description: >
            Wire-bytes overflow (`payload_too_large`, > 10 MB), gzip-bomb
            defense (`decompressed_too_large`, > 50 MB after decompression),
            or row-count overflow (`too_many_rows`, > 50,000 orders).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                payload_too_large:
                  value:
                    error: "Payload too large"
                    code: "payload_too_large"
                decompressed_too_large:
                  value:
                    error: "Decompressed payload too large"
                    code: "decompressed_too_large"
                too_many_rows:
                  value:
                    error: "Too many orders in one request"
                    code: "too_many_rows"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /order_refunds:
    post:
      operationId: postOrderRefunds
      summary: Upload order refund data
      description: >
        Upload parsed refund rows from TCGplayer's order export. The request body
        is shape-validated synchronously, then queued for upsert in a background
        job — the response is `202 Accepted` and the actual database write happens
        asynchronously. Refund rows are upserted by `(user_id, order_number)`,
        either creating new orders or enriching existing ones with refund metadata.

        Both raw `application/json` and gzip-compressed JSON are accepted. For
        large batches gzip is preferred — set `Content-Type: application/gzip`
        and gzip the JSON body. The server transparently decompresses gzip and
        falls back to raw bytes when the body is not gzip-encoded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/OrderRefundItem'
              minItems: 1
            example:
              - order_number: "ORD-12345"
                buyer_name: "John Doe"
                product_amt: 24.99
                shipping_amt: 0.99
                total_amt: 25.98
                order_date: "3/27/2026"
                status: "Refunded"
                refund_type: "full"
                refund_origin: "tcgplayer"
          application/gzip:
            schema:
              type: string
              format: binary
              description: >
                A gzip-compressed JSON array of refund rows. Same schema as the
                `application/json` body, just compressed.
      responses:
        "202":
          description: >
            Refunds accepted for asynchronous import. The shape was validated
            synchronously; the upsert runs in a background job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "accepted"
                  count:
                    type: integer
                    description: Number of refund rows in the request.
                    example: 1
              example:
                status: "accepted"
                count: 1
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: >
            Invalid JSON, invalid gzip body (when Content-Type is gzip), empty
            array, non-array body, or rows missing `order_number`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                invalid_gzip:
                  value:
                    error: "Body is not valid gzip"
                    code: "invalid_gzip"
                invalid_payload:
                  value:
                    error: "Expected an array of items"
                    code: "invalid_payload"
                empty_payload:
                  value:
                    error: "No items"
                    code: "empty_payload"
        "413":
          description: >
            Wire-bytes overflow (`payload_too_large`, > 10 MB), gzip-bomb defense
            (`decompressed_too_large`, > 50 MB after decompression), or row-count
            overflow (`too_many_rows`, > 50,000 refund rows).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                payload_too_large:
                  value:
                    error: "Payload too large"
                    code: "payload_too_large"
                decompressed_too_large:
                  value:
                    error: "Decompressed payload too large"
                    code: "decompressed_too_large"
                too_many_rows:
                  value:
                    error: "Too many items in one request"
                    code: "too_many_rows"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /order_items:
    post:
      operationId: postOrderItems
      summary: Upload order line items
      description: >
        Upload parsed line items for existing orders. The request body is
        shape-validated synchronously, then queued for upsert in a background
        job — the response is `202 Accepted` and the actual database write
        happens asynchronously. Items are upserted by `(order_id, sku_id)`,
        with sku-less rows always inserted (the unique index allows multiple
        NULL `sku_id` rows per order). Items whose `order_number` has no
        matching order owned by the user are silently skipped.

        Both raw `application/json` and gzip-compressed JSON are accepted. For
        large batches gzip is preferred — set `Content-Type: application/gzip`
        and gzip the JSON body. The server transparently decompresses gzip and
        falls back to raw bytes when the body is not gzip-encoded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/OrderLineItem'
              minItems: 1
            example:
              - order_number: "ORD-12345"
                product_name: "Lightning Bolt"
                product_line: "Magic"
                condition: "Near Mint"
                set_name: "Alpha"
                rarity: "C"
                quantity: 2
                sku_id: 12345
                main_photo_url: "https://example.com/bolt.jpg"
          application/gzip:
            schema:
              type: string
              format: binary
              description: >
                A gzip-compressed JSON array of line items. Same schema as the
                `application/json` body, just compressed.
      responses:
        "202":
          description: >
            Items accepted for asynchronous import. The shape was validated
            synchronously; the upsert runs in a background job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "accepted"
                  count:
                    type: integer
                    description: Number of line items in the request.
                    example: 1
              example:
                status: "accepted"
                count: 1
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: >
            Invalid JSON, invalid gzip body (when Content-Type is gzip), empty
            array, or non-array body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                invalid_gzip:
                  value:
                    error: "Body is not valid gzip"
                    code: "invalid_gzip"
                invalid_payload:
                  value:
                    error: "Expected an array of order items"
                    code: "invalid_payload"
                empty_payload:
                  value:
                    error: "No items"
                    code: "empty_payload"
        "413":
          description: >
            Wire-bytes overflow (`payload_too_large`, > 10 MB), gzip-bomb defense
            (`decompressed_too_large`, > 50 MB after decompression), or row-count
            overflow (`too_many_rows`, > 50,000 items).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                payload_too_large:
                  value:
                    error: "Payload too large"
                    code: "payload_too_large"
                decompressed_too_large:
                  value:
                    error: "Decompressed payload too large"
                    code: "decompressed_too_large"
                too_many_rows:
                  value:
                    error: "Too many items in one request"
                    code: "too_many_rows"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /orders/shipping:
    post:
      operationId: postOrderShipping
      summary: Upload order shipping enrichment
      description: >
        Upload shipping data (carrier, address, tracking, weight) to enrich
        existing orders. The request body is shape-validated synchronously,
        then queued for a bulk SQL update in a background job — the response
        is `202 Accepted` and the database write happens asynchronously.
        Updates are keyed on `(user_id, order_number)`; unknown order numbers
        are silently skipped.

        Both raw `application/json` and gzip-compressed JSON are accepted. For
        large batches gzip is preferred — set `Content-Type: application/gzip`
        and gzip the JSON body. The server transparently decompresses gzip and
        falls back to raw bytes when the body is not gzip-encoded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/OrderShippingItem'
              minItems: 1
            example:
              - order_number: "ORD-12345"
                shipping_method: "USPS First Class"
                city: "Portland"
                state: "OR"
                postal_code: "97201"
                tracking_number: "9400111899223100001234"
                item_count: 3
                product_weight: 0.5
          application/gzip:
            schema:
              type: string
              format: binary
              description: >
                A gzip-compressed JSON array of shipping rows. Same schema as
                the `application/json` body, just compressed.
      responses:
        "202":
          description: >
            Shipping rows accepted for asynchronous update. The shape was
            validated synchronously; the bulk UPDATE runs in a background job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "accepted"
                  count:
                    type: integer
                    description: Number of shipping rows in the request.
                    example: 1
              example:
                status: "accepted"
                count: 1
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: >
            Invalid JSON, invalid gzip body (when Content-Type is gzip), empty
            array, non-array body, or rows missing `order_number`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                invalid_gzip:
                  value:
                    error: "Body is not valid gzip"
                    code: "invalid_gzip"
                invalid_payload:
                  value:
                    error: "Expected an array of items"
                    code: "invalid_payload"
                empty_payload:
                  value:
                    error: "No items"
                    code: "empty_payload"
        "413":
          description: >
            Wire-bytes overflow (`payload_too_large`, > 10 MB), gzip-bomb defense
            (`decompressed_too_large`, > 50 MB after decompression), or row-count
            overflow (`too_many_rows`, > 50,000 shipping rows).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                payload_too_large:
                  value:
                    error: "Payload too large"
                    code: "payload_too_large"
                decompressed_too_large:
                  value:
                    error: "Decompressed payload too large"
                    code: "decompressed_too_large"
                too_many_rows:
                  value:
                    error: "Too many items in one request"
                    code: "too_many_rows"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /order_feedbacks:
    post:
      operationId: postOrderFeedbacks
      summary: Upload seller feedback ratings
      description: >
        Upload seller feedback ratings scraped from TCGplayer. Matches each
        feedback item to an existing order by `order_number` and stores the
        rating and comment. Items with no matching order or invalid ratings
        (outside 1-5) are silently skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/OrderFeedbackItem'
              minItems: 1
            example:
              - order_number: "ORD-12345"
                rating: 5
                comment: "Fast shipping, great packaging!"
      responses:
        "200":
          description: Feedback processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "ok"
                  updated:
                    type: integer
                    description: >
                      Number of orders where feedback actually changed. Returns
                      0 when all matched orders already had identical feedback
                      (agent uses this as a "caught up" signal to stop pagination).
                    example: 1
              example:
                status: "ok"
                updated: 1
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: Invalid JSON or empty array.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                empty_payload:
                  value:
                    error: "No feedback"
                    code: "empty_payload"
        "413":
          description: Payload exceeds 10 MB global limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Payload too large"
                code: "payload_too_large"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sales_report:
    post:
      operationId: postSalesReport
      summary: Upload monthly sales report
      description: >
        Upload a parsed sales report for a specific date range. Records are
        upserted by `(user_id, date)` so re-uploading the same month is safe.

        If all revenue fields are zero, the month is still recorded as covered
        so the gaps endpoint stops requesting it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                date_from:
                  type: string
                  format: date
                  description: Start of the reporting period (first day of the month).
                  example: "2026-03-01"
                date_to:
                  type: string
                  format: date
                  description: End of the reporting period.
                  example: "2026-03-27"
                report:
                  $ref: '#/components/schemas/SalesReportData'
              required:
                - date_from
                - date_to
                - report
            example:
              date_from: "2026-03-01"
              date_to: "2026-03-27"
              report:
                gross_sales: 1234.56
                order_count: 42
                product_amount: 1100.00
                total_fees: 134.56
                shipping_amount: 41.58
                total_refund_amount: 0.00
                refunded_orders: 0
                refunded_fees: 0.00
                adjustments: 0.00
                net_sales_minus_fees: 965.44
                net_sales: 1100.00
      responses:
        "200":
          description: Sales report saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "ok"
                  month:
                    type: string
                    description: Human-readable month label.
                    example: "Mar 2026"
                  empty:
                    type: boolean
                    description: True if the report contained no revenue data.
                    example: false
              example:
                status: "ok"
                month: "Mar 2026"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: Invalid JSON, unparseable date, or missing report object.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid data: unexpected token at ..."
                    code: "invalid_json"
                invalid_payload:
                  value:
                    error: "Missing or invalid report data"
                    code: "invalid_payload"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sales_report/gaps:
    get:
      operationId: getSalesReportGaps
      summary: Get months missing sales data
      description: >
        Returns months (as date strings) that have no sales report data, so
        the agent knows what to backfill. Looks back to the earliest order
        date or 24 months, whichever is further. Returns an empty array if
        no orders have been imported yet.

        The agent backfills up to 2 months per sync cycle, oldest first.
      responses:
        "200":
          description: List of months missing sales report data.
          content:
            application/json:
              schema:
                type: object
                properties:
                  gaps:
                    type: array
                    items:
                      type: string
                      format: date
                      description: First day of a month that needs a sales report.
                    example: ["2026-01-01", "2026-02-01"]
              example:
                gaps:
                  - "2026-01-01"
                  - "2026-02-01"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /repricing_events:
    post:
      operationId: postRepricingEvents
      summary: Record a price import event
      description: >
        Called by the agent after it successfully imports price updates into
        TCGplayer. Records the event for analytics (outcome tracking). Also
        completes any active price rollback that was being imported.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                card_count:
                  type: integer
                  description: Number of cards whose prices were updated.
                  example: 47
                occurred_at:
                  type: string
                  format: date-time
                  description: >
                    When the repricing import occurred (ISO 8601). Defaults to
                    current server time if omitted.
                  example: "2026-04-20T09:15:00Z"
              required:
                - card_count
      responses:
        "201":
          description: Repricing event recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: "ok"
                  card_count:
                    type: integer
                    description: Card count echoed back from the saved record.
                    example: 47
              example:
                status: "ok"
                card_count: 47
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: Invalid JSON or validation failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  value:
                    error: "Invalid JSON body"
                    code: "invalid_json"
                validation_failed:
                  value:
                    error: "Card count can't be blank"
                    code: "validation_failed"
        "413":
          description: Payload exceeds 10 MB global limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Payload too large"
                code: "payload_too_large"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /sync/log:
    post:
      operationId: postSyncLog
      summary: Upload sync cycle log
      description: >
        Report the results of a completed sync cycle. The agent sends this at
        the end of every cycle whether it succeeded or failed. The server stores
        it for troubleshooting via the dashboard sync log view.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: ["success", "partial", "failed"]
                  description: Overall outcome of the sync cycle.
                  example: "success"
                steps:
                  type: array
                  items:
                    $ref: '#/components/schemas/SyncLogStep'
                  description: Per-step results.
                agent_version:
                  type: string
                  description: Agent version string that ran this cycle.
                  example: "0.4.9"
                platform:
                  type: string
                  description: Agent platform (e.g., `darwin/arm64`).
                  example: "darwin/arm64"
                duration_ms:
                  type: integer
                  description: Total sync cycle duration in milliseconds.
                  example: 45000
                started_at:
                  type: string
                  format: date-time
                  description: When the sync cycle started. Defaults to current server time if omitted.
                  example: "2026-04-20T09:00:00Z"
              required:
                - status
      responses:
        "201":
          description: Sync log saved.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "422":
          description: Validation failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Status can't be blank"
                code: "validation_failed"
        "413":
          description: Payload exceeds 10 MB global limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Payload too large"
                code: "payload_too_large"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /agent/step_result:
    post:
      operationId: postAgentStepResult
      summary: Report a single sync step outcome
      description: >
        Per-step durability for the dependency-aware sync flow. The agent
        POSTs the result of every step immediately after it completes, so
        a cycle that crashes mid-flight still leaves a durable record of
        the steps that already succeeded. The server's FlowPlanner reads
        these rows to derive freshness decisions on the next cycle. A
        retry for the same `(cycle_id, step_name)` replaces the prior row.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cycle_id, step_name, status, started_at, completed_at]
              properties:
                cycle_id:
                  type: string
                  description: The sync cycle this step ran inside.
                  example: "c5f8a1b3d9e74f02"
                step_name:
                  type: string
                  description: One of the agent's registered step names.
                  example: "price_import"
                status:
                  type: string
                  enum: ["success", "failed", "skipped"]
                started_at:
                  type: string
                  format: date-time
                completed_at:
                  type: string
                  format: date-time
                error:
                  type: string
                  description: Human-readable failure message (only when status=failed).
                detail:
                  type: string
                  description: Optional JSON-encoded blob of step-specific detail.
      responses:
        "201":
          description: Step result saved.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "422":
          description: Validation failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /sync/debug:
    post:
      operationId: postSyncDebug
      summary: Upload debug screenshot
      description: >
        Send a Base64-encoded PNG screenshot to the server when a sync step
        fails. The screenshot is stored against the most recent sync log entry
        and is visible from the dashboard for troubleshooting.

        The screenshot must be Base64-encoded and must not exceed 2 MB decoded.
        A 404 is returned if no sync log entry exists for this account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                step_name:
                  type: string
                  description: Name of the step that failed.
                  example: "login"
                page_url:
                  type: string
                  description: URL of the page at the time of the screenshot.
                  example: "https://store.tcgplayer.com/admin"
                screenshot:
                  type: string
                  description: Base64-encoded PNG screenshot data.
                captured_at:
                  type: string
                  format: date-time
                  description: When the screenshot was captured.
                  example: "2026-04-20T09:01:30Z"
      responses:
        "201":
          description: Debug screenshot saved.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "404":
          description: No sync log entry found for this account.
        "413":
          description: Screenshot exceeds 2 MB decoded size.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Screenshot exceeds 2MB limit (2097153 bytes decoded)"
                code: "payload_too_large"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /desktop/update:
    get:
      operationId: getDesktopUpdate
      summary: Check for desktop app update
      description: >
        Called by the Hoard desktop wrapper to check if a new version is
        available. Uses the same ed25519 signing chain as agent releases,
        filtered to `component=desktop`.

        Pass the current version and platform as query parameters. Returns
        update info including download URL and signature when an update is
        available.
      parameters:
        - in: query
          name: version
          required: false
          schema:
            type: string
            example: "0.2.0"
          description: Current desktop app version string.
        - in: query
          name: platform
          required: false
          schema:
            type: string
            example: "darwin/arm64"
          description: >
            Desktop platform identifier (e.g., `darwin/arm64`, `windows/amd64`,
            `linux/amd64`).
      responses:
        "200":
          description: Update check result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DesktopUpdateResponse'
              examples:
                no_update:
                  summary: Already on latest version
                  value:
                    update_available: false
                    force_update: false
                update_available:
                  summary: Newer version available
                  value:
                    update_available: true
                    force_update: false
                    latest_version: "0.2.1"
                    download_url: "https://releases.tryhoard.com/hoard-desktop-0.2.1-darwin-arm64"
                    signature: "base64encodedSignature..."
                    sha256: "abc123..."
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /tasks/claim:
    post:
      operationId: claimTask
      summary: Claim the next pending agent task
      description: >
        Atomically claims the highest-priority pending task for the authenticated
        user. Uses FOR UPDATE SKIP LOCKED to prevent double-claims across
        concurrent agents. Stale claims (older than 10 minutes) are automatically
        reset to pending before claiming. Tasks include historical backfills and
        focused refreshes such as refresh_orders. Returns null if no tasks are
        available.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Task claimed, or no tasks available.
          content:
            application/json:
              schema:
                type: object
                properties:
                  task:
                    type:
                      - object
                      - "null"
                    description: The claimed task, or null if none available.
                    properties:
                      id:
                        type: integer
                        description: Task ID. Use this to report completion or failure.
                        example: 42
                      type:
                        type: string
                        description: Task type (backfill_orders, refresh_orders, backfill_feedback, backfill_sales).
                        enum:
                          - backfill_orders
                          - refresh_orders
                          - backfill_feedback
                          - backfill_sales
                        example: "refresh_orders"
                      params:
                        type: object
                        description: Task-specific parameters (date ranges, max_pages, etc).
                        example: { "date_from": "2025-01-01", "date_to": "2025-12-31" }
              example:
                task:
                  id: 42
                  type: "refresh_orders"
                  params: { "date_from": "2026-05-07", "date_to": "2026-05-07" }
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"

  /pull_sessions/{id}/orders/{order_id}/defer:
    patch:
      operationId: deferPullSessionOrder
      summary: Move an order to the end of an active Pull queue
      description: >
        Marks one order in the authenticated user's active Pull session as
        deferred. Deferred orders remain in the session but sort after active
        orders so a puller can keep moving when a card cannot be found.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: Active Pull session ID.
        - name: order_id
          in: path
          required: true
          schema:
            type: integer
          description: Order ID inside the Pull session.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  example: "card not found"
      responses:
        "200":
          description: Pull session order deferred.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pull_session_order:
                    type: object
                    properties:
                      order_id:
                        type: integer
                      deferred_at:
                        type: string
                        format: date-time
                      deferred_reason:
                        type: string
              example:
                pull_session_order:
                  order_id: 123
                  deferred_at: "2026-05-07T18:30:00Z"
                  deferred_reason: "card not found"
        "404":
          description: Active Pull session or order not found for this user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /tasks/{id}:
    patch:
      operationId: updateTask
      summary: Report a claimed task as completed or failed
      description: >
        Updates the status of a previously claimed task. Only tasks in "claimed"
        status can be updated. Pass "completed" with a result object, or "failed"
        with an error string.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: ID of the task to update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [completed, failed]
                  description: New status for the task.
                result:
                  type: object
                  description: Task output (when status is "completed").
                  example: { "records_imported": 42 }
                error:
                  type: string
                  description: Failure reason (when status is "failed").
                  example: "Export timed out after 120s"
              required:
                - status
      responses:
        "200":
          description: Task status updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "404":
          description: Task not found or belongs to another user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Not found"
                code: "not_found"
        "422":
          description: Task is not in claimed status, or invalid status value.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Task is not in claimed status"
                code: "validation_failed"

  /pricing_rules/config:
    get:
      operationId: getPricingRulesConfig
      summary: Get pricing wizard configuration
      description: >
        Returns game-specific search operators, pre-built rule recipes, and
        the user's active product lines. Used by the pricing rule wizard UI
        to populate operator pickers and recipe templates.
      responses:
        "200":
          description: Wizard configuration payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  games:
                    type: object
                    description: >
                      Map of game keys to operator definitions. Each game includes
                      label, operators hash, visible_by_default list, and source.
                  active_product_lines:
                    type: array
                    items:
                      type: string
                    description: Product lines enabled for this account.
                  local_operators:
                    type: array
                    items:
                      type: string
                    description: Operators available across all games (set, rarity, price, etc).
                  recipes:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        game:
                          type: string
                          nullable: true
                        query:
                          type: string
                        multiplier:
                          type: number
                        never_go_down:
                          type: boolean
                        description:
                          type: string
                    description: Pre-built rule templates for common pricing strategies.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /pricing_rules/test:
    get:
      operationId: getPricingRulesTest
      summary: Test which rule matches a card
      description: >
        Given a card_id, returns the pricing rule that would apply to it
        (if any), along with the computed target price. Used by the wizard
        to let users verify rule coverage on specific cards.
      parameters:
        - in: query
          name: card_id
          required: true
          schema:
            type: integer
          description: ID of the inventory card to test.
      responses:
        "200":
          description: Matched rule and target price for the card.
          content:
            application/json:
              schema:
                type: object
                properties:
                  card:
                    type: object
                    properties:
                      id:
                        type: integer
                      product_name:
                        type: string
                      product_line:
                        type: string
                      set_name:
                        type: string
                  matched_rule:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      query_text:
                        type: string
                      multiplier:
                        type: string
                        nullable: true
                      priority:
                        type: integer
                  target_price:
                    type: string
                    description: Formatted target price (e.g. "12.50").
                  using_default:
                    type: boolean
                    description: True when no rule matched and the global default was used.
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "404":
          description: Card not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /inventory_card_events/{id}:
    patch:
      operationId: patchInventoryCardEvent
      summary: Set or clear a price override on an inventory card event
      description: >
        Manually override the captured marketplace price for a specific inventory
        card event. Pass a positive number to set the override; pass null to clear
        it. The effective_price field always returns COALESCE(price_override,
        price_per_unit).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: ID of the inventory card event to update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                price_override:
                  type:
                    - number
                    - "null"
                  description: >
                    Positive number to override the captured price, or null to
                    clear a previously set override.
                  example: 12.50
      responses:
        "200":
          description: Price override applied or cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    description: ID of the inventory card event.
                    example: 42
                  price_per_unit:
                    type:
                      - number
                      - "null"
                    description: Original captured marketplace price at sync time.
                    example: 10.00
                  price_override:
                    type:
                      - number
                      - "null"
                    description: User-set price override, or null if cleared.
                    example: 12.50
                  price_overridden_at:
                    type:
                      - string
                      - "null"
                    format: date-time
                    description: When the override was last set, or null if cleared.
                    example: "2026-04-24T10:00:00Z"
                  effective_price:
                    type:
                      - number
                      - "null"
                    description: >
                      COALESCE(price_override, price_per_unit). The price used
                      for consignor settlement calculations.
                    example: 12.50
              example:
                id: 42
                price_per_unit: 10.00
                price_override: 12.50
                price_overridden_at: "2026-04-24T10:00:00Z"
                effective_price: 12.50
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Invalid or missing API key"
                code: "unauthorized"
        "403":
          description: Account suspended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Account suspended"
                code: "account_suspended"
        "404":
          description: Event not found or belongs to another user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Couldn't find InventoryCardEvent"
                code: "not_found"
        "422":
          description: price_override is not a positive number.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "price_override must be a positive number"
                code: "validation_failed"
        "429":
          description: Rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "Rate limit exceeded"
                code: "rate_limited"

  /pricing/explain/{tcgplayer_id}:
    get:
      operationId: getPricingExplain
      summary: Diagnose why a specific card is priced where it is
      description: >
        Returns the governing pricing rule + a diagnostic blocker code in a
        single call. Used by AI assistants asking "why is THIS card priced
        where it is?" — the server-side resolver removes the need for the
        agent to LLM-reason about query_text matching a single card.

        Returns a `why_not_repricing` code (one of `price_locked`,
        `never_go_down_floor`, `market_zero`, `stale_sync`,
        `updates_disabled`, `read_only_mode`, or `null`) plus a
        human-readable `diagnosis` array.
      tags: [pricing, mcp]
      parameters:
        - name: tcgplayer_id
          in: path
          required: true
          schema:
            type: integer
            format: int64
          description: The card's TCGplayer SKU id (negative for manual quick-add entries).
      responses:
        "200":
          description: Pricing diagnostic payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  card:
                    type: object
                    description: Card snapshot (listed/market/target/status/locked).
                  governing_rule:
                    nullable: true
                    type: object
                    description: The highest-priority enabled rule matching this card, or null when default applies.
                  default_applied:
                    type: boolean
                  default:
                    type: object
                  matching_rules:
                    type: array
                    description: All enabled rules matching this card in priority order.
                  why_not_repricing:
                    nullable: true
                    type: string
                    enum: [price_locked, never_go_down_floor, market_zero, stale_sync, updates_disabled, read_only_mode]
                  diagnosis:
                    type: array
                    items:
                      type: string
                  sync:
                    type: object
                    description: last_sync_at, hours_since, sync_mode.
        "404":
          description: Card not in inventory.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: "card not found in your inventory"
                code: "card_not_found"
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /sync/health:
    get:
      operationId: getSyncHealth
      summary: Pre-baked sync verdict (one call returns assessment + next action)
      description: >
        Returns a single verdict code, a user-facing message, and a
        next-action code instead of raw fields. Use for "is everything ok?"
        questions instead of GET /sync/status + threshold math.

        Verdict precedence: sync_in_progress > agent_offline > sync_failing
        > sync_stale > read_only_warning > no_history > healthy. First
        matching condition wins.
      tags: [sync, mcp]
      responses:
        "200":
          description: Health verdict.
          content:
            application/json:
              schema:
                type: object
                properties:
                  verdict:
                    type: string
                    enum: [healthy, sync_in_progress, agent_offline, sync_failing, sync_stale, read_only_warning, no_history]
                  user_message:
                    type: string
                    description: One-sentence health summary; safe to show end-users verbatim.
                  next_action:
                    nullable: true
                    type: string
                    enum: [wait, restart_desktop, check_logs, request_sync, flip_sync_mode]
                  details:
                    type: object
                    description: Raw fields the verdict was derived from (sync_in_progress, last_sync_at, agent_last_seen_at, sync_mode, product_lines, hours_since_sync, minutes_since_agent).
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /analytics/sales_diagnostic:
    get:
      operationId: getSalesDiagnostic
      summary: Ranked hypotheses for "why are my sales down (or up)?"
      description: >
        Runs MoM, YoY, trailing-12-month, fee-rate, sale-event-baseline,
        refund-rate, and repricing-volume checks server-side. Returns
        hypotheses ranked by evidence strength + caveats (what Hoard
        CAN'T see — demand-side signals like TCGplayer traffic).

        Use this instead of pulling raw salesData and reasoning client-side
        for any "why" question about sales.
      tags: [analytics, mcp]
      parameters:
        - name: month
          in: query
          required: false
          schema:
            type: string
            pattern: '^\d{4}-\d{1,2}$'
          description: Target month as YYYY-MM. Omit for most recent month with data.
      responses:
        "200":
          description: Diagnostic payload with ranked hypotheses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  target_month:
                    type: string
                  history_months_available:
                    type: integer
                  plan_history_limited:
                    type: boolean
                  current_month:
                    nullable: true
                    type: object
                  vs_last_month:
                    nullable: true
                    type: object
                  vs_yoy_same_month:
                    nullable: true
                    type: object
                  trailing_12_avg:
                    nullable: true
                    type: object
                  fee_rate_alert:
                    nullable: true
                    type: object
                  sale_events_in_baseline:
                    type: array
                  hypotheses:
                    type: array
                    items:
                      type: object
                      properties:
                        factor:
                          type: string
                          enum:
                            - fee_rate_change
                            - sale_events_in_baseline_window
                            - seasonal_pattern
                            - refund_rate_spike
                            - large_repricing_run
                            - no_meaningful_change
                            - no_specific_signal
                            - no_history
                            - month_not_found
                        evidence:
                          type: string
                        confidence:
                          type: string
                          enum: [high, medium, low]
                        direction:
                          type: string
                  caveats:
                    type: array
                    items:
                      type: string
        "401":
          description: Invalid or missing API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /analytics/actionable_movers:
    get:
      operationId: getActionableMovers
      summary: Pre-filtered "what should I act on?" movers list
      description: >
        Wraps /api/movers with portfolio-weighted sort, sustained-volatility
        filter (drops one-snapshot spikes), minimum dollar impact threshold,
        and unlocked-cards-only. Returns each surviving row with a suggestion
        string and a FilterStats block showing why a small result is small.

        Use this instead of /api/movers when the user is asking "what should
        I check?" or "what should I act on?" — it removes the filter-chain
        boilerplate every agent rebuilds by hand.
      tags: [analytics, mcp]
      parameters:
        - name: window
          in: query
          required: false
          schema: { type: integer, enum: [1, 7, 30, 90, 365] }
          description: Window in days. Free plan accepts [1, 7]; pro plan all five.
        - name: min_dollar_impact
          in: query
          required: false
          schema: { type: number, minimum: 0 }
          description: Minimum absolute portfolio impact to surface a row. Default 25.
        - name: product_line
          in: query
          required: false
          schema: { type: string }
        - name: format_filter
          in: query
          required: false
          schema:
            type: string
            enum: [standard, future, historic, timeless, gladiator, pioneer, explorer, modern, legacy, pauper, vintage, penny, commander, brawl, historicbrawl, alchemy, paupercommander, duel, oldschool, premodern, predh, ub]
          description: Magic-only format scope. Requires product_line=Magic; otherwise 422.
      responses:
        "200":
          description: Actionable movers payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window_days: { type: integer }
                  allowed_windows: { type: array, items: { type: integer } }
                  min_dollar_impact_applied: { type: number }
                  sort_by: { type: string }
                  actionable_gainers: { type: array }
                  actionable_losers: { type: array }
                  filter_stats:
                    type: object
                    properties:
                      candidates_seen: { type: integer }
                      dropped_locked: { type: integer }
                      dropped_spike: { type: integer }
                      dropped_under_threshold: { type: integer }
                  caveats: { type: array, items: { type: string } }
        "422":
          description: Invalid window, format_filter, or scope combination.
        "401":
          description: Invalid or missing API key.

  /analytics/consignors:
    get:
      operationId: getAnalyticsConsignors
      summary: Per-consignor sell-through and remaining stock
      description: >
        Aggregates integer-valued FIFO event rows by consignor over a window.
        Reports tracked sell-through (sold_qty, sold_value), computed owed for
        consignment-model consignors, and remaining_qty derived from the event
        ledger. Overdraft units (oversold beyond books) are reported separately
        so sellers can distinguish normal sell-through from negative books.

        Use this for "which consignors are moving?" and "what do I owe whom?"
        questions; it's a single query, no client-side aggregation.
      tags: [analytics, mcp]
      parameters:
        - name: window
          in: query
          required: false
          schema: { type: string, enum: ["7", "30", "90", "365", "all"] }
          description: Window in days, or "all" for lifetime. Default 30.
        - name: fee_basis
          in: query
          required: false
          schema: { type: string, enum: [gross, net] }
          description: gross uses list price; net applies the 89.75% take rate minus $0.99. Default gross.
      responses:
        "200":
          description: Per-consignor performance payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window: { type: string }
                  fee_basis: { type: string }
                  overdraft_units: { type: integer, description: Units oversold beyond tracked books inside the window. }
                  consignors:
                    type: array
                    items:
                      type: object
                      properties:
                        consignor_id: { type: integer }
                        name: { type: string }
                        sold_qty: { type: integer }
                        sold_value: { type: number }
                        owed:
                          type: number
                          nullable: true
                          description: Null for non-consignment models (buy_in, floor).
                        remaining_qty: { type: integer }
        "401":
          description: Invalid or missing API key.
        "422":
          description: Invalid window.

  /pricing/diagnose_store:
    get:
      operationId: getDiagnoseStore
      summary: Store-wide pricing-blocker aggregate
      description: >
        Aggregates pricing blockers across the whole inventory in a single
        SQL pass. Returns bucket counts (price_locked, updates_disabled,
        market_zero, never_go_down_floor, on_target), 5 samples per bucket
        sorted by listed price desc, and systemic warnings (read_only_mode,
        agent_offline, stale_sync) that make per-card diagnosis academic.

        Use this BEFORE looping explainCard across many cards — it returns
        the systemic picture in one round trip.
      tags: [pricing, mcp]
      responses:
        "200":
          description: Store-wide diagnostic payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_inventory: { type: integer }
                  blocker_buckets:
                    type: object
                    additionalProperties: { type: integer }
                  largest_blocker_bucket:
                    nullable: true
                    type: string
                  samples_per_bucket:
                    type: object
                  systemic_warnings:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                          enum: [read_only_mode, agent_offline, stale_sync]
                        message:
                          type: string
                  caveats: { type: array, items: { type: string } }
        "401":
          description: Invalid or missing API key.

  /agent/plans:
    post:
      operationId: createAgentPlan
      summary: Create a plan for a write action (preview/confirm/commit flow)
      description: >
        Creates an AgentActionPlan for a write action (currently
        pricing.edit_rule). Returns a diff + risk + requires_confirmation
        flag. If requires_confirmation is true, the caller surfaces
        confirmation_browser_url to the user (a browser page at
        /agent/plans/:plan_id that renders the diff and an Approve
        button). When the user approves there, the server mints a
        confirmation_token and the caller can commit within 60 seconds
        via POST /agent/plans/:plan_id/commit. confirmation_url remains
        in the response as a JSON POST endpoint at
        /api/v1/agent/plans/:plan_id/confirm for session-authed callers
        that prefer JSON over the browser flow.

        Pass dry_run: true to force requires_confirmation=true regardless
        of grant + risk — useful for "preview and tell the user" chat
        flows where you want to show the diff before any auto-commit
        path fires.

        Floor violations (absolute_minimum, per_rule_aggregate_drop,
        per_session_aggregate_drop) are checked BEFORE dry_run is honored
        and return 422 floor_violation regardless of preview intent.
      tags: [pricing, agent, mcp]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action_namespace, params]
              properties:
                action_namespace:
                  type: string
                  enum: [pricing.edit_rule]
                params:
                  type: object
                dry_run:
                  type: boolean
                  description: Force requires_confirmation=true regardless of grant/risk.
      responses:
        "200":
          description: Plan created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan_id: { type: string }
                  action_namespace: { type: string }
                  diff:
                    type: object
                    properties:
                      cards_affected: { type: integer }
                      before_aggregate: { type: number }
                      after_aggregate: { type: number }
                      aggregate_drop_pct: { type: number }
                  risk: { type: string, enum: [low, medium, high, critical] }
                  requires_confirmation: { type: boolean }
                  confirmation_token: { type: string, nullable: true }
                  confirmation_url:
                    type: string
                    nullable: true
                    description: JSON POST endpoint for session-authed confirm. Legacy field.
                  confirmation_browser_url:
                    type: string
                    nullable: true
                    description: Browser-friendly review + Approve page. Surface this to the seller.
                  params_hash: { type: string }
                  expires_at: { type: string }
                  token_expires_at: { type: string, nullable: true }
                  context:
                    type: object
                    description: |
                      Inline context blob for the agent. rule_of_thumb is a
                      short imperative for this action_namespace, doc_url is
                      the public docs URL (resolvable via normal HTTP),
                      key_constraints exposes the user's current floor + cap
                      values for this action, error_guidance is present only
                      on error responses.
                    nullable: true
                    properties:
                      rule_of_thumb: { type: string }
                      doc_url: { type: string }
                      key_constraints:
                        type: object
                        additionalProperties: true
                      error_guidance: { type: string, nullable: true }
        "403":
          description: denied_by_grant — permission grant blocks this action. Response body includes a `context` object with `error_guidance` keyed to the rejection.
        "422":
          description: floor_violation — plan breaches a safety floor. Response body includes a `context` object with `error_guidance` keyed to the rejection.
