openapi: 3.0.3
info:
  title: mypips API
  version: "1.0.1"
  description: |
    REST API for mypips stores. Authenticate with a per-store API key issued
    from the store management UI (Settings → Integrations → API keys).

    The plain key is shown once at creation; store it in a secret manager.
servers:
  - url: https://api.mypips.app/api/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Introduction
    description: |
      The mypips API is a REST surface for store integrations — POS
      systems, inventory tools, marketing automations, headless
      storefronts.

      - **Base URL:** `https://api.mypips.app/api/v1`
      - **All paths nest under a store:** `/stores/{storeHandle}/...`.
        The handle is the store's public identifier in the mypips admin
        (Settings → Store), restricted to `[A-Za-z0-9._-]`.
      - **Server-to-server.** The API has no CORS allow-list — calls
        from browsers fail at preflight. Front-ends should proxy
        through their own backend.
      - **Single version.** All endpoints live under `/v1`. Breaking
        changes will land under `/v2` when needed.
  - name: Authentication
    description: |
      Every authenticated request carries a per-store API key as a
      bearer token:

      ```
      Authorization: Bearer <api_key>
      ```

      API keys are issued from the mypips store admin: **Settings →
      Integrations → API keys**. The plain key value is shown once at
      creation — store it in a secret manager. Each key is bound to
      one store; calls aimed at another store return `404` (no info
      leak across stores).

      Each key carries a set of scopes, granted at issue time. A
      request with the wrong scope returns `403`. Available scopes:

      | Scope               | Grants                                |
      | ------------------- | ------------------------------------- |
      | `products_read`     | Read products + categories            |
      | `products_write`    | Create / update products              |
      | `inventory_read`    | Read per-product inventory            |
      | `inventory_write`   | Update per-product inventory          |
      | `content_read`      | Read editorial content                |
      | `content_write`     | Create / update editorial content     |
      | `pages_read`        | Read custom pages                     |
      | `pages_write`       | Create / update / patch custom pages  |
      | `media_write`       | Upload images for custom pages        |
      | `orders_read`       | Read orders                           |
      | `orders_write`      | Create / update / cancel orders       |
      | `deliveries_read`   | List in-fulfillment deliveries        |
      | `deliveries_write`  | Reorder a carrier's deliveries / route|
      | `accounts_read`     | Read customer accounts                |
      | `accounts_write`    | Create / update customer accounts     |
      | `promotions_read`   | List + validate promo codes           |
      | `transactions_read` | Read payment transactions report      |
      | `stores_write`      | Update store profile + logo/cover     |

      The `Deliveries` surface accepts EITHER its own dedicated
      `deliveries_read` / `deliveries_write` scope OR the broader
      `orders_read` / `orders_write` scope. Grant a server-to-server
      integration (e.g. a courier / route-planning system) just the
      `deliveries_*` scope to give it least-privilege delivery access
      without exposing the rest of the order surface.

      The `Reports` surface aggregates store-wide sales data and is
      gated by dedicated scopes that intentionally do NOT overlap with
      the per-shipment / per-customer scopes:
      `GET /reports/range` requires `reports_read` (granted ONLY to
      the `manager` role — packer / carrier / operations / support
      etc. cannot reach it even though they hold `orders_read`).
      `GET /reports/transactions` requires `transactions_read`.

      Failure modes:

      - `401` — header missing or token invalid
      - `403` — token valid but missing the required scope, or aimed
        at another store
  - name: Making requests
    description: |
      - **Encoding.** All request and response bodies are JSON. Send
        `Content-Type: application/json` on every write.
      - **Body limit.** 100 KiB on every authenticated REST route (`413`
        if exceeded). The single exception is the media-upload endpoint,
        which accepts up to 15 MiB so a base64-encoded 10 MiB image fits.
        The MCP surface (see "MCP access" below) uses a separate 1 MiB
        cap to allow larger tool-call payloads.
      - **Path identifiers.** `storeHandle` matches `[A-Za-z0-9._-]`.
        Resource ids match `[A-Za-z0-9_-]{6,64}`. Anything outside
        these patterns is rejected with `400` before the handler runs.
      - **Customer naming.** The API exposes the customer's name only
        as `fullName` — there is no `firstName` / `lastName` split on
        the wire.
      - **Allow-list writes.** Most write endpoints accept a fixed
        allow-list of fields. Unknown fields (typos, future fields,
        non-sensitive extras) are silently dropped and reported back
        in `ignoredFields` so callers can see what was filtered.
      - **Protected fields.** On account write endpoints
        (`POST /accounts` and `POST /accounts/{accountId}`) the
        platform-stamped identity, ownership, and soft-delete fields
        are **rejected with `400`** rather than silently dropped —
        notably the customer's `uid` (immutable post-creation; every
        cart is bound to the customer through it, so mutating it
        would orphan order history) and `createdAt`. The error
        response names the specific rejected field so a caller can
        identify it at runtime.
  - name: Responses
    description: |
      Every response carries a `success` boolean and a status code that
      matches its envelope:

      - **Success** — `{ "success": true, ...resource }` (status `200`
        on read/update, `201` on create)
      - **Failure** — `{ "success": false, "error": "<short message>" }`
        (status 4xx/5xx)

      Status code reference:

      | Code | Meaning                                                            |
      | ---- | ------------------------------------------------------------------ |
      | 200  | OK (read or update)                                                |
      | 201  | Created                                                            |
      | 400  | Validation error — body or query failed contract                   |
      | 401  | Missing / invalid `Authorization` header                           |
      | 403  | Wrong scope, OR token belongs to a different store                 |
      | 404  | Not found, OR cross-store access (intentionally indistinguishable) |
      | 409  | State conflict (e.g. cancel-order on an already-canceled order)    |
      | 413  | Body exceeded the limit (100 KiB; 15 MiB for media upload)         |
      | 429  | Rate-limited; respect the `Retry-After` header                     |
      | 500  | Server-side error — please report                                  |
      | 503  | Platform audit subsystem temporarily unavailable; safe to retry    |

      Errors are intentionally short and opaque — internal stack
      traces are NEVER echoed back. Treat the `error` string as a hint;
      branch on the status code, not on substrings of the message.

      **Retry policy — `429` and `503`.** Both indicate the platform
      refused the request rather than completing it. Implement
      exponential backoff: start at the `Retry-After` value (or 1 s if
      the header is absent), double on repeated failures, cap at 30 s.

      **Retry-safety contract.** On `5xx`, the platform may have
      partially committed work before refusing the response. For
      this reason, every write operation in this API is either
      naturally idempotent (the same input twice lands on the same
      final state) OR documented as additive (the same input twice
      creates a second resource). Where idempotency matters, the
      endpoint either takes an explicit identity field or uses
      optimistic-concurrency (`expectedVersion`) — see the per-endpoint
      docs. Treat any `5xx` retry as "may have already succeeded";
      handlers must be designed to tolerate that.
  - name: Pagination
    description: |
      List endpoints use **cursor pagination** (not offset). Pass:

      ```
      GET /api/v1/stores/{storeHandle}/products?limit=20&cursor=<opaque>
      ```

      | Query param | Notes                                                              |
      | ----------- | ------------------------------------------------------------------ |
      | `limit`     | Per-resource hard cap (typically 20–200). Out-of-range = `400`.    |
      | `cursor`    | Opaque token from a previous response's `nextCursor`.              |

      Response shape:

      ```json
      {
        "success": true,
        "count": 20,
        "hasMore": true,
        "nextCursor": "eyJ2YWx1ZSI6Im9yYW5nZS...",
        "<resource>": [ ... ]
      }
      ```

      - `nextCursor` is `null` on the last page.
      - Cursors are **opaque** — never parse, splice, or mutate them.
        Pass them back verbatim. The cursor format may change at any
        time without bumping the API version.
  - name: Rate limits
    description: |
      Each API key is rate-limited at **60 requests per minute** by
      default, on a hard 60-second window (not sliding). Exceeding the
      limit returns `429 Too Many Requests` with a
      `Retry-After: <seconds>` header indicating how long to wait
      before the next request.

      If you need a higher limit (e.g. for bulk import or a busy
      production integration), ask the store owner — limits can be
      raised per-key.

      **Best practices:**

      - Implement exponential backoff on `429` — start at the
        `Retry-After` value, then double on repeated failures.
      - Avoid bursts by spacing requests evenly across the minute
        window.
      - For batch operations, prefer fewer-larger calls over
        many-small calls when an endpoint supports it (e.g.
        cursor-paginated list reads vs N individual GETs).
  - name: MCP access
    description: |
      In addition to the REST endpoints documented here, mypips exposes
      a Model Context Protocol (MCP) server so AI agents (Claude.ai,
      Cursor, custom integrations) can call store data as tools.

      **Endpoint URLs.** Exactly two MCP surfaces are served:

      | Surface                                | URL                                   | Audience                        |
      | -------------------------------------- | ------------------------------------- | ------------------------------- |
      | Merchant (store-side integrations)     | `https://mcp.mypips.app/mcp/merchant` | Store-bound merchant API keys + merchant-grant OAuth tokens |
      | Shopper (signed-in end-user agents)    | `https://mcp.mypips.app/mcp/shopper`  | Shopper-grant OAuth tokens only |

      The bare `https://mcp.mypips.app/mcp` URL — without an audience
      suffix — returns `404` with a structured hint pointing at the two
      valid surfaces. Clients must address their target surface
      explicitly; there is no implicit default.

      Each surface advertises its own discovery metadata at
      `<surface>/.well-known/oauth-protected-resource`, so MCP clients
      automatically discover the right audience to request. A token
      minted for one surface is rejected with `401` on the other.

      **Authentication.** Merchant-surface calls accept either a mypips
      API key (same `Authorization: Bearer <api_key>` as the REST
      surface) or an OAuth access token. Shopper-surface calls accept
      ONLY shopper-role OAuth — API keys are rejected. The OAuth issuer
      lives at `https://api.mypips.app/oauth` — its
      `/.well-known/oauth-authorization-server` discovery document
      describes the available endpoints.

      **Body limit.** The MCP surface uses a **1 MiB** body cap (vs
      100 KiB for REST). Tool-call payloads can legitimately exceed the
      REST cap; the 1 MiB cap is enforced at the transport layer
      regardless of which tool is invoked.

      **Rate limits.** Apply per-key (merchant surface, 60/min) and
      per-user (shopper surface, 120/min). The two windows are
      independent — shopper traffic never inflates a merchant's
      per-key budget.

      **Tool catalog.** Use the built-in `findTool` meta-tool to search
      the catalog by keyword and/or category instead of loading every
      tool description into context. Tool annotations include
      `readOnlyHint`, `destructiveHint`, and `idempotentHint` per the
      MCP spec, plus `mypips/category` and `mypips/tags` for catalog
      discovery (Hebrew tags auto-injected for bilingual queries).

      Detailed setup instructions for popular MCP clients (Claude.ai,
      Cursor, custom integrations) live in the mypips public docs —
      see the connector setup guide.
  - name: Store
    description: |
      Store metadata exposed to integrations: api info / health and the
      store's public-facing settings doc.
  - name: Delivery option
    description: |
      The store's active delivery and pickup options. Inactive options
      are filtered out — these are essentially the customer-facing
      shipping config the storefront shows at checkout.
  - name: Account
    description: |
      Customer accounts on the store. Search by phone or email, create,
      fetch, update. Includes the customer-scoped orders endpoint that
      requires both `accounts_read` AND `orders_read`.
  - name: Product
    description: |
      Catalog products. List and search, create, fetch, and update.
      List reads are always filtered to `forSale=true` and
      `inStock=true`; removed products are never returned (no opt-out).
  - name: Category
    description: |
      The store's configured category list. Pass any value verbatim as
      the `category` query arg on the product list endpoint.
  - name: Inventory
    description: |
      Per-product stock state — `track` flag, `qty`, low-stock threshold,
      auto-`outOfStock` policy. Reads return a default not-tracked
      shape when no entry exists yet.
  - name: Supplier
    description: |
      Vendors that supply the store's products. Every product references
      its supplier via `supplierId` (required on writes). Manage the
      supplier catalog here — search, fetch, create, update. Suppliers
      can be shared across multiple stores, but the API only ever
      returns suppliers attached to the current key's store; ownership
      and soft-delete bookkeeping are stripped from the wire shape.
  - name: Order
    description: |
      Order lifecycle: search by status, fetch one, create (pending
      cart or finalized), update conservative metadata, or run the
      cancel pipeline. Money / line-item / fulfillment-state fields
      flow through the cart-pricing pipeline and are NOT exposed here.
  - name: Delivery
    description: |
      Read-only projection over in-fulfillment orders shaped for
      delivery / route-planning workflows. Each row carries the
      delivery customer, delivery option, carrier, computed box count,
      and order totals — the same row shape the store admin's CSV
      export emits. Gated any-of: `deliveries_read` / `deliveries_write`
      (dedicated, API-key least-privilege) OR `orders_read` /
      `orders_write`.
  - name: Page
    description: |
      Custom pages — block-based landing pages keyed by slug. Includes
      a patch surface for atomic block-level edits with optimistic
      `expectedVersion` checks.
  - name: Content
    description: |
      Editorial content items (`type=content`) — articles, posts, etc.
      They share storage with products but are surfaced as their own
      resource so callers don't have to filter by type.
  - name: Media
    description: |
      Image upload for custom-page blocks. Direct base64 flow accepts
      up to 15 MiB per request.
  - name: Promo code
    description: |
      Promo code list and validation. Validation is read-only and does
      NOT mutate state — redemption happens through the order finalize
      pipeline.
  - name: Report
    description: |
      Range and transactions reports. These mirror the aggregations
      shown in the store admin's "Reports" page — per-order rows,
      per-product rollups, key figures, and the payment transactions
      log. Reports are read-only and capped at a 100-day window plus
      hard count ceilings (5000 orders / 10000 transactions per call)
      so a misbehaving integration can't trigger a runaway scan.

      Date inputs are ISO 8601 UTC; numeric coercion follows the rest
      of the API (missing → 0 for sum-style totals; missing dates →
      `null`).
  - name: Price list
    description: |
      Store price lists (read-only on the public API). Each list carries
      a public-facing `linkId` slug — the same value referenced by
      `Page.priceListLinkId` and storefront URLs. Lookups are available
      both by id and by linkId. `linkId` is enforced unique within a
      store on create / update (excluding soft-deleted lists); the
      by-link endpoint relies on that uniqueness.

      Products are exposed by mode, mutually exclusively: table mode
      returns `includedProducts` (the per-product price-override map —
      membership + per-unit prices + a `type:"content"` marker; product
      names are NOT included here, resolve them via the products endpoint)
      with `rulesData: null`; rules mode returns sanitized `rulesData` with
      `includedProducts: null`. `productsCount` summarises table mode and
      is `null` in rules mode.
  - name: Shopper API
    description: |
      Building a shopper-facing client (mobile app, headless storefront,
      AI shopping assistant) that signs the end user in directly?
      That surface lives on its own — different base URL, different
      authentication, different docs page. See the dedicated reference:

      - **Docs:** [`https://api.mypips.app/docs/shopper-api`](https://api.mypips.app/docs/shopper-api)
      - **Base URL:** `https://api.mypips.app/shopper/v1`
      - **Auth:** identity-token Bearer (NOT a per-store API key)

      The two surfaces share no auth and no quota. Merchant integrations
      stay on this page; shopper clients use the shopper docs.
  - name: Changelog
    description: |
      ## 1.0.1 — 2026-06-04

      **Orders: date-range filtering + pagination.**
      `GET /stores/{handle}/orders` can filter finalized orders by a date
      range — pass `startTime` + `endTime` with `fetchBy=order_date` (when
      the order was finalized) or `fetchBy=delivery_date` (the chosen
      delivery date). The endpoint is now paginated (`cursor` / `nextCursor`
      / `hasMore`) with the maximum page size raised from 200 to 500.

      **Price list on the `Order` schema.** Orders now expose `priceListId`
      and `priceListLinkId` — the price list the order was placed against.

      ## 1.0.0 — 2026-05 · Initial release

      The mypips API. Per-store access, authenticated with an API key or via
      OAuth, gated by fine-grained scopes. Resources: accounts, orders
      (including create and cancel), products, inventory, transactions,
      suppliers, categories, store metadata, delivery options, promo-code
      validation, invoices, custom pages and editorial content, and price
      lists — plus date-range reports.

      Behavioral contracts worth knowing up front: order `paymentMethod` is
      a server-enforced enum of the methods the storefront offers; create
      endpoints are idempotent (re-creating a matching record returns the
      existing one instead of duplicating it); and every authenticated
      endpoint documents a `503` response so generated clients include a
      retry branch.

      A hosted MCP server is available alongside the REST surface — a
      merchant surface (`https://mcp.mypips.app/mcp/merchant`, for any
      merchant-side role) and a separate shopper surface
      (`https://mcp.mypips.app/mcp/shopper`).
x-tagGroups:
  - name: Getting started
    tags: [Introduction, Authentication, "Making requests", Responses, Pagination, "Rate limits", "MCP access"]
  - name: Store properties
    tags: [Store, "Delivery option"]
  - name: Customers
    tags: [Account]
  - name: Products
    tags: [Product, Category, Inventory, Supplier]
  - name: Orders
    tags: [Order, Delivery]
  - name: Content
    tags: [Page, Content, Media]
  - name: Price lists
    tags: ["Price list"]
  - name: Discounts
    tags: ["Promo code"]
  - name: Reports
    tags: [Report]
  - name: Other surfaces
    tags: ["Shopper API"]
  - name: Release notes
    tags: [Changelog]
paths:
  /:
    get:
      summary: Get API info and health
      tags: [Store]
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  api: { type: string, example: mypips }
                  version: { type: string, example: v1 }
                  docs: { type: string, format: uri }
  /stores/{storeHandle}:
    get:
      summary: Get store metadata
      tags: [Store]
      description: |
        Returns the public-safe shape of the store record. No scope required —
        any valid API key for the store can fetch its own metadata.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      responses:
        "200":
          description: Store metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  store: { $ref: "#/components/schemas/Store" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update store profile
      tags: [Store]
      description: |
        Required scope: `stores_write`. Allow-list update of the store's
        PUBLIC PROFILE — the shopper-facing fields a new store needs filled
        in so it stops looking like a demo. Pass the same nested shape
        `GET /stores/{storeHandle}` returns; anything not allow-listed comes
        back in `ignoredFields`. Nested maps (`mainContact`, `links`) are
        merged per-field — setting one sub-field leaves the siblings intact,
        and a link is cleared by sending an empty string.

        Editable: `name`, `description`, `contact.phoneNumber`,
        `mainContact.{firstName,lastName,whatsapp}`,
        `links.{website,fb,instagram,youtube,tiktok,twitter}`.

        NOT settable here (by design): `logoUrl` / `coverUrl` (use
        `POST /stores/{storeHandle}/media/store/upload`), `contact.email`,
        order-taking / minimum-order, and VAT settings.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: includeImages
          required: false
          schema: { type: string, enum: ["true", "1", "yes"] }
          description: Include `logoUrl` + `coverUrl` in the returned store.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StoreUpdate" }
      responses:
        "200":
          description: Updated store
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  store: { $ref: "#/components/schemas/Store" }
                  ignoredFields: { type: array, items: { type: string } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/delivery-options:
    get:
      summary: Get delivery & pickup options
      tags: ["Delivery option"]
      description: |
        Returns the delivery + pickup options the store offers, split
        into two arrays. By default only `active` options are returned
        — pass `activeOnly=0` to include inactive/disabled options too.
        Each option carries `active: true|false` on the wire so callers
        can filter client-side.

        No scope required — these are essentially public-facing config
        customers see when placing an order on the storefront.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: activeOnly
          required: false
          schema: { type: string, enum: ["0", "1", "true", "false"], default: "1" }
          description: |
            `"1"` / `"true"` (default) — only return options with
            `active: true`. `"0"` / `"false"` — include inactive
            options too.
      responses:
        "200":
          description: Delivery + pickup options
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  delivery:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryOption" }
                  pickup:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryOption" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/accounts/{accountId}/orders:
    get:
      summary: List a customer's orders
      tags: [Account]
      description: |
        Requires **BOTH** `accounts_read` AND `orders_read` scopes (403
        otherwise). Always scoped to the calling key's store and the
        given accountId. Same `Order` shape as the orders search
        endpoint.

        **Prefer `GET /customers/{uid}/orders`** when you already hold
        a customer `uid` — that endpoint skips the accountId lookup
        and also surfaces guest orders that were placed before any
        account row was created.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/accountId"
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, in_fulfillment, completed, all]
            default: all
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        "200":
          description: Customer's orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  accountId: { type: string }
                  status: { type: string }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/promo-codes:
    get:
      summary: List promo codes
      tags: ["Promo code"]
      description: |
        Returns the store's promo codes. Default `activeOnly=true`
        drops disabled / used-up / out-of-window entries. Hard cap 100.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: activeOnly
          schema: { type: string, enum: ["0", "1", "true", "false"], default: "1" }
      responses:
        "200":
          description: Promo code list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count: { type: integer }
                  promoCodes:
                    type: array
                    items: { $ref: "#/components/schemas/PromoCode" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/promo-codes/{code}/validate:
    post:
      summary: Validate a promo code (read-only check, does not mutate)
      tags: ["Promo code"]
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: path
          name: code
          required: true
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{1,32}$" }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: |
                Supply at most one of `uid` / `accountId` to enable the
                per-customer usage-limit check. `uid` is preferred —
                it skips the accounts lookup. `accountId` and `uid`
                are mutually exclusive (400 if both supplied).
              properties:
                accountId:
                  type: string
                  description: |
                    Per-store account id. Mutually exclusive with
                    `uid`. Resolves to the account's uid, then queries
                    the customer's prior carts.
                uid:
                  type: string
                  pattern: "^[A-Za-z0-9_-]{20,128}$"
                  description: |
                    Platform-wide customer identity. Preferred when
                    available — no accounts lookup needed. Mutually
                    exclusive with `accountId`.
                cartTotal:
                  type: number
                  description: Reserved. Currently ignored.
      responses:
        "200":
          description: Validation result. Always 200; check `valid` and `reason`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  valid: { type: boolean }
                  reason:
                    oneOf:
                      - { type: "null" }
                      - { type: string, enum: [not_found, not_active, reached_total_usage_limit, reached_per_customer_usage_limit] }
                  promoCode:
                    oneOf:
                      - { type: "null" }
                      - { $ref: "#/components/schemas/PromoCode" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/suppliers:
    get:
      summary: Search suppliers
      tags: [Supplier]
      description: |
        Required scope: `suppliers_read`. Always scoped to the calling
        key's store; soft-deleted suppliers are never returned (no
        opt-out). Optional `q` is a case-insensitive substring match on
        `name` (2–60 chars); whitespace-separated words are ANDed, so each
        word must appear somewhere in the name (order independent).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: q
          description: >-
            Case-insensitive substring match on `name`. Whitespace-separated
            words are ANDed (each must appear somewhere in the name).
          schema: { type: string, minLength: 2, maxLength: 60 }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          schema: { type: string }
      responses:
        "200":
          description: Supplier list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  suppliers:
                    type: array
                    items: { $ref: "#/components/schemas/Supplier" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      summary: Create a supplier
      tags: [Supplier]
      description: |
        Required scope: `suppliers_write`. Required body field: `name`.
        Ownership, soft-delete state, and audit timestamps are managed
        automatically by the server.

        **Idempotency.** If the request `name` matches an existing
        non-deleted supplier already visible to this store
        (case-insensitive), the endpoint returns HTTP **200** (not 201)
        with `existing: true` and the existing supplier instead of
        creating a duplicate. The check is best-effort.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupplierCreate" }
      responses:
        "200":
          description: Existing supplier matched by name — no new doc was created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  existing: { type: boolean, example: true }
                  supplier: { $ref: "#/components/schemas/Supplier" }
                  supplierId: { type: string }
                  message: { type: string }
        "201":
          description: Supplier created (full body when key has suppliers_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      supplier: { $ref: "#/components/schemas/Supplier" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/SupplierWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/suppliers/{supplierId}:
    get:
      summary: Get one supplier
      tags: [Supplier]
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: path
          name: supplierId
          required: true
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
      responses:
        "200":
          description: Supplier
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  supplier: { $ref: "#/components/schemas/Supplier" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update a supplier
      tags: [Supplier]
      description: |
        Required scope: `suppliers_write`. Allow-list update — unknown
        or server-managed fields are silently dropped and reported back
        in `ignoredFields`. Empty string on `email` clears the field.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: path
          name: supplierId
          required: true
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupplierUpdate" }
      responses:
        "200":
          description: Supplier updated (full body when key has suppliers_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      supplier: { $ref: "#/components/schemas/Supplier" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/SupplierWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/categories:
    get:
      summary: List categories
      tags: [Category]
      description: |
        Returns the store's category list (plain strings — pass any of
        them verbatim as the `category` query arg on `/products`).
        Internal markers (`@`-prefixed attribute categories) are stripped
        server-side. No extra read cost: the list comes from the store
        metadata already loaded for auth.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      responses:
        "200":
          description: Category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count: { type: integer }
                  categories:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Create a category
      tags: [Category]
      description: |
        Required scope: `products_write`. Adds a manual category to the
        store's category list. Names cannot start with `@` (reserved for
        attribute-derived categories) or contain `%` / `\`. Idempotent —
        creating an existing category is a no-op (HTTP **200** with
        `existing: true`, aligned with the rest of the create surface).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category]
              properties:
                category: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "201":
          description: Category created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  category: { type: string }
        "200":
          description: |
            Existing category matched by name — no new entry created.
            Aligned with the platform-wide idempotency contract: every
            `POST /stores/{handle}/<resource>` returns `200 + existing: true`
            on a natural-unique-key match instead of a 409. Safe for
            retry-on-network-blip flows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  existing: { type: boolean, example: true }
                  category: { type: string }
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/categories/rename:
    post:
      summary: Rename a category (cascades to products + price lists)
      tags: [Category]
      description: |
        Required scope: `products_write`. Renames a category and cascades
        the rename to every product carrying the old name and every
        price list that references it. Both `from` and `to` are
        validated by the same rules as create.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to]
              properties:
                from: { type: string, minLength: 1, maxLength: 120 }
                to: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "200":
          description: Category renamed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  from: { type: string }
                  to: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/products:
    get:
      summary: Search products
      tags: [Product]
      description: |
        Returns the catalog. Removed products are never returned
        (security/billing — no opt-out). By default also filtered to
        `forSale=true` AND `inStock=true`; pass `includeAll=true` to
        drop both filters. `outOfStock` is never used as a filter — it's
        purely a "show but mark unavailable" hint and is always returned
        on the wire. Results ordered by `name` ascending. `q` is a
        case-insensitive SUBSTRING match across the product's text fields —
        name, sku, title, supplier name, description, text (whitespace-
        separated words are ANDed; each must appear in some field) — and
        REQUIRES `category` to be set (no unbounded full-catalog scans).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: category
          schema: { type: string, maxLength: 120 }
        - in: query
          name: q
          description: >-
            Case-insensitive substring match across the product's text fields
            (name, sku, title, supplier name, description, text; whitespace-
            separated words ANDed). Requires `category`.
          schema: { type: string, minLength: 2, maxLength: 60 }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: includeAll
          description: |
            When truthy (`true` / `1` / `yes`), drops the default
            `forSale=true` AND `inStock=true` filters. Use for
            manager-style audits / reconciliation. The wire-level
            `forSale` / `inStock` / `outOfStock` fields reveal each
            product's actual state.
          schema: { type: string, enum: [true, "true", "1", "yes", false, "false", "0", "no"] }
      responses:
        "200":
          description: Product list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  products:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl https://api.mypips.app/api/v1/stores/mystore/products?category=Vegetables&limit=20 \
              -H "Authorization: Bearer $MYPIPS_API_KEY"
        - lang: javascript
          label: Node.js
          source: |
            const res = await fetch(
                'https://api.mypips.app/api/v1/stores/mystore/products?category=Vegetables&limit=20',
                { headers: { Authorization: `Bearer ${process.env.MYPIPS_API_KEY}` } },
            );
            const { products, hasMore, nextCursor } = await res.json();
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.get(
                'https://api.mypips.app/api/v1/stores/mystore/products',
                params={'category': 'Vegetables', 'limit': 20},
                headers={'Authorization': f"Bearer {os.environ['MYPIPS_API_KEY']}"},
            )
            r.raise_for_status()
            data = r.json()
    post:
      summary: Create a product
      tags: [Product]
      description: |
        Required scope: `products_write`. The body uses an allow-list —
        unknown or server-managed fields are silently ignored and
        reported back in `ignoredFields`. Required: `name`, `category`,
        `supplierId`, `prices`, `forSale`. `forSale` sets storefront
        visibility and must be decided at creation. `inStock` is always
        `true` on a new product — stock availability is managed by supplier
        activation afterward — so any `inStock` sent in the create body is
        ignored. A new product is also stamped with a 10-day storefront
        "new" badge window. Both `category` and `supplierId` are validated
        against the store on every write — a `400` response names the
        missing entity and the endpoint to create it on.

        `supplierName` is **caller-controlled** (not auto-derived from
        the supplier). On create: omitted → defaults to `""`;
        explicit `""` → saved as `""`; any other string → saved verbatim
        regardless of the referenced supplier's `name`/`displayName`.
        On update: omitted → field is left untouched; `""` → saved as
        empty; any other string → saved verbatim.

        **`sku`.** SKUs are NOT unique — a store may reuse a SKU across
        variants or locations — so create never deduplicates on it: every
        call creates a brand-new product (always `201`). When `sku` is
        omitted or empty the server generates one, so every product carries
        a SKU; a caller-provided `sku` is saved verbatim.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductCreate" }
      responses:
        "201":
          description: Product created (full body when key has products_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      product: { $ref: "#/components/schemas/Product" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/ProductWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/products/{productId}:
    get:
      summary: Get one product
      tags: [Product]
      description: |
        `404` when the product is missing, cross-store, soft-deleted, or
        is a content item (`type=content`). By default also `404` when
        `forSale=false` or `inStock=false` — pass `includeAll=true` to
        drop those filters and inspect the full record (the wire-level
        `forSale` / `inStock` / `outOfStock` fields reveal the actual
        state). Soft-delete and content guards still apply with
        `includeAll=true`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/productId"
        - in: query
          name: includeAll
          description: |
            When truthy (`true` / `1` / `yes`), drops the default
            `forSale=true` AND `inStock=true` filters. Soft-delete and
            content-doc 404s still apply.
          schema: { type: string, enum: [true, "true", "1", "yes", false, "false", "0", "no"] }
      responses:
        "200":
          description: Product
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  product: { $ref: "#/components/schemas/Product" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update a product
      tags: [Product]
      description: |
        Required scope: `products_write`. Allow-list update — unknown /
        internal fields land in `ignoredFields`. `404` when the doc is
        missing, in another store, soft-deleted, or is a content item
        (`type=content`).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/productId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProductUpdate" }
      responses:
        "200":
          description: Product updated (full body when key has products_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      product: { $ref: "#/components/schemas/Product" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/ProductWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/content:
    post:
      summary: Create editorial content
      tags: [Content]
      description: |
        Required scope: `content_write`. Allow-list body. `type` is
        always server-stamped to `"content"` and cannot be set from the
        body. Required: `category` and at least one of
        `title` / `text` / `images`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentCreate" }
      responses:
        "201":
          description: Content created (full body when key has content_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      content: { $ref: "#/components/schemas/Content" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/ContentWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/content/{contentId}:
    get:
      summary: Get one content item
      tags: [Content]
      description: |
        Required scope: `content_read`. `404` when the doc is missing,
        in another store, soft-deleted, or is a product (`type !== "content"`).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/contentId"
      responses:
        "200":
          description: Content
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  content: { $ref: "#/components/schemas/Content" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update editorial content
      tags: [Content]
      description: |
        Required scope: `content_write`. Allow-list update. `type` is
        not settable. `404` for the same reasons as `GET`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/contentId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContentUpdate" }
      responses:
        "200":
          description: Content updated (full body when key has content_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      content: { $ref: "#/components/schemas/Content" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/ContentWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/pages:
    get:
      summary: Search custom pages
      tags: [Page]
      description: |
        Required scope: `pages_read`. Returns pages owned by the API key's
        store. `priceListLinkId="" ` filters to public pages
        (`priceListLinkId == null`); a non-empty value filters to that
        link id; omitting the param skips the filter. `enabledOnly=1`
        (default) hides disabled pages.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - { in: query, name: priceListLinkId, schema: { type: string }, required: false }
        - { in: query, name: enabledOnly, schema: { type: string, enum: ["0","1","true","false"] }, required: false }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100 }, required: false }
        - { in: query, name: cursor, schema: { type: string }, required: false }
      responses:
        "200":
          description: Page list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  pages:
                    type: array
                    items: { $ref: "#/components/schemas/Page" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      summary: Create a page
      tags: [Page]
      description: |
        Required scope: `pages_write`. Required body fields: `slug`, `title`.
        Page id is server-generated. Ownership and audit timestamps are
        managed automatically by the server.

        **Idempotency.** The composite key `(slug, priceListLinkId)` is
        unique per store. If a page with the same slug AND the same
        `priceListLinkId` already exists, the endpoint returns HTTP
        **200** (not 201) with `existing: true` and the existing page
        instead of creating a duplicate. The same slug with a DIFFERENT
        `priceListLinkId` is treated as a distinct page (intentional —
        the same URL structure can vary by price-list variant). The
        check is best-effort.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PageWrite" }
      responses:
        "200":
          description: Existing page matched by (slug, priceListLinkId) — no new doc was created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  existing: { type: boolean, example: true }
                  page: { $ref: "#/components/schemas/Page" }
                  pageId: { type: string }
                  message: { type: string }
        "201":
          description: Page created (full body when key has pages_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      page: { $ref: "#/components/schemas/Page" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/PageWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/pages/{pageId}:
    get:
      summary: Get one page
      tags: [Page]
      description: |
        Required scope: `pages_read`. `404` when the page is missing or in
        another store (no info leak).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/pageId"
      responses:
        "200":
          description: Page
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  page: { $ref: "#/components/schemas/Page" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update a page (full-replace)
      tags: [Page]
      description: |
        Required scope: `pages_write`. Full-replace projection — pass the
        complete page payload. Fields not in the body revert to defaults.
        `404` for the same reasons as `GET`. The handler pre-reads the
        existing doc and refuses cross-store ids without mutating state.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/pageId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PageWrite" }
      responses:
        "200":
          description: Page updated (full body when key has pages_read, else write-receipt)
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      success: { type: boolean }
                      page: { $ref: "#/components/schemas/Page" }
                      ignoredFields:
                        type: array
                        items: { type: string }
                  - $ref: "#/components/schemas/PageWriteReceipt"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/pages/{pageId}/patch:
    post:
      summary: Apply a sequence of patch ops to a page atomically
      tags: [Page]
      description: |
        Required scope: `pages_write`. Apply ops to one page atomically
        without re-sending the full body. Each block-targeting op uses
        a stable `blockId` (indexes are intentionally not accepted —
        they would shift mid-batch). The page record carries a monotonic
        `version` integer; pass `expectedVersion` to refuse stale
        writes (`409` with the current state).

        Op types: `replace`, `setEnabled`, `set`, `addBlock`,
        `moveBlock`, `removeBlock`. Hard cap: 50 ops per request. See
        the README for full op-shape rules.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/pageId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PagePatchRequest" }
      responses:
        "200":
          description: Patch applied (full body when key has pages_read)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PagePatchReceipt"
                  - allOf:
                      - $ref: "#/components/schemas/PagePatchReceipt"
                      - type: object
                        properties:
                          page: { $ref: "#/components/schemas/Page" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: expectedVersion mismatch
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:   { type: string, example: "Version conflict" }
                  currentVersion: { type: integer }
                  page: { $ref: "#/components/schemas/Page" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/media/custom-pages/upload:
    post:
      summary: Upload a custom-page image
      tags: [Media]
      description: |
        Required scope: `media_write`. Direct base64 upload — body must be
        a JSON object with `contentType`, `dataBase64`, and an optional
        `filename`. The endpoint accepts up to 15 MiB body (vs. 100 KiB
        elsewhere) so a base64-encoded 10 MiB image fits. The returned
        `media.url` is a permanent download URL the caller can paste
        into a page block's `imageUrl`.

        Allowed content types: `image/png`, `image/jpeg`, `image/webp`,
        `image/gif`. SVG is intentionally excluded (no DOM-aware
        sanitizer yet).

        The signed-URL flow (`{ "flow": "signed-url", ... }`) is
        documented but not yet implemented; it returns `501`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MediaUploadDirect" }
      responses:
        "201":
          description: Image stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  media: { $ref: "#/components/schemas/Media" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "501":
          description: Signed-URL flow not yet implemented
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:   { type: string }
  /stores/{storeHandle}/media/store/upload:
    post:
      summary: Upload store logo or cover
      tags: [Store]
      description: |
        Required scope: `stores_write`. Uploads the store's logo or cover
        image and stamps it onto the store profile in ONE call. Direct
        base64 upload — `kind` (`logo` | `cover`), `contentType`,
        `dataBase64`, and an optional `filename`. Accepts up to 15 MiB body
        (a base64-encoded 10 MiB image). The image is stored behind a
        permanent download URL which is set as the store's logo or cover;
        any previous one is replaced.

        Allowed content types: `image/png`, `image/jpeg`, `image/webp`,
        `image/gif`. SVG is intentionally excluded.

        Store images are settable ONLY through this validated upload —
        `POST /stores/{storeHandle}` does not accept raw image URLs.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StoreImageUpload" }
      responses:
        "201":
          description: Image stored and stamped on the store profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  kind:  { type: string, enum: [logo, cover] }
                  url:   { type: string }
                  path:  { type: string }
                  store: { $ref: "#/components/schemas/Store" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/orders/{orderId}:
    get:
      summary: Get one order
      tags: [Order]
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/orderId"
      responses:
        "200":
          description: Order
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  order: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update an order (conservative allow-list)
      tags: [Order]
      description: |
        Money / line-item / fulfillment-state fields are NOT exposed here —
        those flow through the cart-pricing pipeline. Use the dedicated
        items / fulfill endpoints when they ship.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/orderId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderUpdate" }
      responses:
        "200":
          description: Updated order (or write-receipt if no `orders_read` scope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  order: { $ref: "#/components/schemas/Order" }
                  orderId: { type: string }
                  changedFields:
                    type: array
                    items: { type: string }
                  ignoredFields:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
    delete:
      summary: Delete (soft-close) a pending cart
      tags: [Order]
      description: |
        Soft-closes a PENDING (draft) cart — marks `closed=true` without
        running the finalize-cancel pipeline. The cart row is retained for
        abandoned-cart analytics. Use the `/cancel` endpoint for finalized
        orders.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/orderId"
      responses:
        "200":
          description: Cart closed (or write-receipt if no `orders_read` scope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  cart: { $ref: "#/components/schemas/Order" }
                  orderId: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            Cart is in a state that doesn't allow soft-delete (already
            finalized — use `/cancel` — or already closed).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /stores/{storeHandle}/orders/{orderId}/cancel:
    post:
      summary: Cancel a finalized order
      tags: [Order]
      description: |
        Runs the platform's cancel pipeline (inventory rollback, store-
        discount cleanup, etc.). Pre-flight checks reject cases where the
        order is already canceled / closed / not finalized / has
        completed payments — see the README for the 409 matrix.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/orderId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                sendEmail:
                  type: boolean
                  default: false
                  description: |
                    Send the cancel-confirmation email. Default false so
                    automations don't email customers by accident.
      responses:
        "200":
          description: Cancelled order
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  order: { $ref: "#/components/schemas/Order" }
                  orderId: { type: string }
                  emailed: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            Order is in a state that doesn't allow cancellation
            (already canceled / closed / not finalized / has completed payments).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /stores/{storeHandle}/orders:
    get:
      summary: Search orders
      tags: [Order]
      description: |
        Lists orders for the store. The `status` filter selects which
        lifecycle stage to return:

        | Status            | Meaning                                  |
        | ----------------- | ---------------------------------------- |
        | pending (default) | Open carts (started, never checked out)  |
        | in_fulfillment    | Checked out, in the fulfillment pipeline |

        **Pagination:** results are paginated. Pass `limit` (up to 500) and
        the `cursor` from the previous response's `nextCursor` to fetch the
        next page; `hasMore` indicates whether more pages remain.

        **Date filtering** (in-fulfillment orders): supply `startTime` +
        `endTime` and `fetchBy` to narrow by a date range.
        `fetchBy=order_date` ranges on when the order was finalized;
        `fetchBy=delivery_date` ranges on the chosen delivery date. A date
        filter with `status=pending` is rejected; when a date filter is
        supplied with no explicit status, `in_fulfillment` is assumed. The
        maximum window is 100 days.

        **Role-scoped results (OAuth):** when the caller is a fulfillment or
        delivery team member, results are limited to the orders that role is
        meant to work on — fulfillment roles see only in-fulfillment orders,
        and delivery roles see only the in-fulfillment orders assigned to
        them (other statuses return empty). Store managers, customer-service,
        and finance roles (and API-key callers) see the full order book.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, in_fulfillment]
            default: pending
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 50
        - in: query
          name: cursor
          description: Opaque pagination cursor from a previous response's `nextCursor`.
          schema: { type: string }
        - in: query
          name: startTime
          description: Date-range filter start (ISO 8601). Requires `endTime`; applies to finalized statuses only.
          schema: { type: string, format: date-time }
        - in: query
          name: endTime
          description: Date-range filter end (ISO 8601). Requires `startTime`.
          schema: { type: string, format: date-time }
        - in: query
          name: fetchBy
          description: Which date axis the range filters on.
          schema:
            type: string
            enum: [order_date, delivery_date]
            default: order_date
      responses:
        "200":
          description: Order search result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  status: { type: string, enum: [pending, in_fulfillment] }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      summary: Create a new order (pending cart or finalized order)
      tags: [Order]
      description: |
        Required scope: `orders_write`.

        Two modes (selected via `mode`):
        - `pending` (default) creates an open cart. No checkout
          pipeline runs. Useful for staging an order, importing from
          another system, or seeding abandoned-cart recovery.
        - `finalize` creates the cart AND immediately runs it through
          the platform's checkout pipeline — VAT, promo-code
          verification, account + store discount application, inventory
          deduction, and (when `sendEmail: true`, the default) the
          order-confirmation email.

        Customer attribution — supply exactly one identity:

        - **`uid`** (preferred when you have one) — the platform-wide
          customer identity. Works for both existing accounts AND
          guest orders. If the uid has no account in this store yet,
          supply `customer` too for the order's details.
        - **`accountId`** — the per-store account id. Order
          details come from the account; `customer` (optional) overrides
          per-field.
        - **`customer`** alone — pure-guest path; a synthetic owner is
          generated.

        `accountId` and `uid` are mutually exclusive — both supplied
        is a `400`. Cart ownership is bound to the resolved customer's
        identity automatically so the new order shows up under the
        same customer identity as their existing history.

        **`422` cases:**
        - `accountId` resolves to a legacy account that lacks an
          internal customer identity. Contact support to backfill the
          account first, or use the `customer`-only path.
        - `uid` has no account in this store AND no `customer` body
          was supplied — we cannot synthesize order details from
          nothing. Either supply `customer` or call `GET /customers/{uid}`
          first to confirm the customer exists in this store.

        `paymentMethod` is an enum of the methods the storefront offers
        (`cash`, `bank_transfer`, `bit`, `paybox`, `pepper`, `check`,
        `credit_card_by_phone`, `other`, `balance`) — free-form values
        are rejected. `credit_card` is recognized but rejected: the API
        does not drive the card-payment flow; use a manual method.

        Server-managed fields (ownership, audit timestamps, key
        attribution, and the initial lifecycle flags `finalized`,
        `closed`, `delivered`, `shipped`, `paid`) are stamped
        automatically and rejected if supplied in the body — forbidden
        body fields are silently dropped.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreate" }
      responses:
        "201":
          description: Created order (or write-receipt if no `orders_read` scope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  mode: { type: string, enum: [pending, finalize] }
                  order: { $ref: "#/components/schemas/Order" }
                  orderId: { type: string }
                  emailed:
                    type: boolean
                    description: Only present when `mode=finalize`. Mirrors `sendEmail`.
                  finalizeStatus:
                    type: string
                    description: Only present when `mode=finalize`. The status returned by the checkout pipeline (`success`, `pending_payment`, `failed`, etc.).
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422":
          description: |
            Either (a) the supplied `accountId` resolves to a legacy
            account that has no internal customer identity (contact
            support to backfill); or (b) the supplied `uid` has no
            account in this store and no `customer` body was supplied
            (we cannot synthesize order details). The order is not
            created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Cart was created but finalize failed (response carries `orderId` for recovery)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:   { type: string }
                  orderId: { type: string }
                  details: { type: string }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |
            curl https://api.mypips.app/api/v1/stores/mystore/orders \
              -H "Authorization: Bearer $MYPIPS_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "mode": "finalize",
                "accountId": "acct_AbCdEf12",
                "items": [{ "productId": "prod_xyz789", "quantity": 2 }],
                "paymentMethod": "cash",
                "deliveryOption": { "id": "pickup-default", "method": "pickup" }
              }'
        - lang: javascript
          label: Node.js
          source: |
            const res = await fetch(
                'https://api.mypips.app/api/v1/stores/mystore/orders',
                {
                    method: 'POST',
                    headers: {
                        Authorization: `Bearer ${process.env.MYPIPS_API_KEY}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({
                        mode: 'finalize',
                        accountId: 'acct_AbCdEf12',
                        items: [{ productId: 'prod_xyz789', quantity: 2 }],
                        paymentMethod: 'cash',
                        deliveryOption: { id: 'pickup-default', method: 'pickup' },
                    }),
                },
            );
            const { orderId, finalize } = await res.json();
        - lang: python
          label: Python
          source: |
            import os, requests
            r = requests.post(
                'https://api.mypips.app/api/v1/stores/mystore/orders',
                headers={
                    'Authorization': f"Bearer {os.environ['MYPIPS_API_KEY']}",
                    'Content-Type': 'application/json',
                },
                json={
                    'mode': 'finalize',
                    'accountId': 'acct_AbCdEf12',
                    'items': [{'productId': 'prod_xyz789', 'quantity': 2}],
                    'paymentMethod': 'cash',
                    'deliveryOption': {'id': 'pickup-default', 'method': 'pickup'},
                },
            )
            r.raise_for_status()
  /stores/{storeHandle}/deliveries:
    get:
      summary: List in-fulfillment deliveries (CSV-export-compatible row shape)
      tags: [Delivery]
      description: |
        Required scope (any-of): `deliveries_read` OR `orders_read`.
        Returns a flat list of
        in-fulfillment orders in a CSV-friendly delivery row shape.
        Each row carries the delivery customer (name, phone, address),
        delivery option (method, date, label), carrier (id + resolved
        name), a computed `noOfBoxes` count, payment method,
        store/customer comments, and the order totalSum.

        Returns finalized orders that are still in fulfillment
        (`finalized=true` AND `closed=false`). Optionally narrowed to one
        carrier:

        | carrierId          | Returns                                                                                            |
        | ------------------ | -------------------------------------------------------------------------------------------------- |
        | _(omitted)_        | every in-fulfillment order, with carrier ids exposed in the row                                    |
        | `none`             | unassigned-delivery orders (no `carrier` set + `deliveryOption.method='delivery'`)                 |
        | `none-pickup`      | unassigned-pickup orders (no `carrier` set + `deliveryOption.method='pickup'`)                     |
        | _(any other)_      | orders whose `carrier` field exactly equals the provided value                                     |

        Field equivalents to the CSV export: column "סוג" → `methodLabel`
        (Hebrew); column "מועד אספקה" → `deliveryDate` (slash-separated,
        mirrors the dot→slash transform applied on export); column
        "פרטים נוספים למשלוח" → `addressDetails` object with
        `entrance`/`code`/`deliveryComment`.

        **Sort order:** when filtered to a single `carrierId`, rows
        are returned in the manager-set sequence — the same order
        shown in the store admin and emitted by the CSV export.
        Orders not yet ranked by the manager fall to the end (sorted
        by deliveryDate then id). When called without a carrier
        filter, rows are grouped by carrier (each group sorted by its
        own manager-set sequence); the unassigned-delivery /
        unassigned-pickup groups land at the end.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: carrierId
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]{1,64}$"
          description: |
            Optional. Carrier UID, or one of `none` / `none-pickup`. See the
            table above. Omit to list every in-fulfillment delivery.
        - { in: query, name: deliveryDate, required: false, schema: { type: string, maxLength: 200 }, description: "Keep only orders whose deliveryOption deliveryDate matches exactly." }
        - { in: query, name: zone, required: false, schema: { type: string, maxLength: 200 }, description: "Keep only delivery orders whose option label (text) matches exactly." }
        - { in: query, name: pickupLocation, required: false, schema: { type: string, maxLength: 200 }, description: "Keep only pickup orders whose option label (text) matches exactly." }
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Delivery rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  carrierId:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  count: { type: integer }
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/Delivery" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/deliveries/{carrierId}/order:
    post:
      summary: Replace the ordered list of deliveries for a carrier (route-planning push-back)
      tags: [Delivery]
      description: |
        Required scope (any-of): `deliveries_write` OR `orders_write`.
        The write-side counterpart of
        `GET /deliveries`. External route-planning integrations call
        the list endpoint to fetch in-fulfillment deliveries for a
        carrier, run their optimization, then POST the optimized order
        back here. The provided `orderIds` array REPLACES the existing
        list for this carrier.

        Heavy guardrails apply (this endpoint can shape what shows up
        on a carrier's actual route — careless writes would break
        operations):

        - `carrierId` must be `none`, `none-pickup`, or a real carrier
          UID registered in the store. Unknown carriers return `404`.
        - Every `orderIds` entry is fetched and verified to be an
          in-fulfillment order in this store assigned to (or
          partitioned under) the same `carrierId`. Any mismatch
          returns `400` with the offending id named — no partial
          writes.
        - Duplicates and oversize lists are rejected.
        - Optional route fields use a strict allow-list. Unknown body
          keys are ignored and reported in `ignoredFields`. Unknown
          `routeStats` sub-keys are reported in
          `ignoredRouteStatsKeys`.
        - The write touches only `orders.{carrierId}` (and optionally
          `routes.{carrierId}`); other carriers' lists and unrelated
          fields are never affected.

        Empty `orderIds` arrays are allowed (they intentionally clear
        the carrier's list).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: path
          name: carrierId
          required: true
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]{1,64}$"
          description: |
            Carrier UID, or `none` (unassigned-delivery), or
            `none-pickup` (unassigned-pickup).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeliveriesOrderUpdate" }
      responses:
        "200":
          description: Order list updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  carrierId: { type: string }
                  count: { type: integer }
                  routesUpdated: { type: boolean }
                  ignoredFields:
                    type: array
                    items: { type: string }
                  ignoredRouteStatsKeys:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/inventory:
    get:
      summary: List all inventory entries (bulk)
      tags: [Inventory]
      description: |
        Required scope: `inventory_read`. Returns every per-product
        inventory entry for the store in a single read. The result may
        include entries for products that have since been removed from
        the catalog; this endpoint does NOT cross-check each entry
        against the products list. Callers that want only entries for
        live products should join the result against `GET /products`
        themselves.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
      responses:
        "200":
          description: Inventory entries (sorted by productId)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count:   { type: integer }
                  inventory:
                    type: array
                    items: { $ref: "#/components/schemas/Inventory" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/products/{productId}/inventory:
    get:
      summary: Get a product's inventory state
      tags: [Inventory]
      description: |
        Required scope: `inventory_read`. A product not yet tracked in
        inventory returns the default not-tracked shape — not a 404.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/productId"
      responses:
        "200":
          description: Inventory entry (default shape when no entry exists yet)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  inventory: { $ref: "#/components/schemas/Inventory" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update a product's inventory state (delta-based, transactional)
      tags: [Inventory]
      description: |
        Required scope: `inventory_write`. Applies a signed `delta` to
        the current `qty` inside a database transaction so concurrent
        writes from different callers compose correctly (no lost
        updates).

        Inventory writes are scoped to the targeted productId, so other
        products' entries are untouched. The product's `outOfStock` /
        `forSale` flags may be flipped atomically when the resulting
        qty hits zero (controlled by `markOutOfStock` and `track`); the
        update only writes those specific fields — no other product
        data is replaced or deleted.

        Direct absolute-qty writes (`qty: N`) are rejected with an
        explicit 400. To set absolute qty: GET the current qty, then
        POST `delta = N - currentQty`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/productId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InventoryUpdate" }
      responses:
        "200":
          description: Updated inventory (or write-receipt if no `inventory_read` scope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  inventory: { $ref: "#/components/schemas/Inventory" }
                  productId: { type: string }
                  prevQty: { type: number }
                  newQty:  { type: number }
                  changedFields:
                    type: array
                    items: { type: string }
                  ignoredFields:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/price-lists:
    get:
      summary: Search price lists
      tags: ["Price list"]
      description: |
        Required scope: `price_lists_read`. Returns price lists in this
        store, ordered by most-recently-updated first (with a stable
        tie-breaker for deterministic pagination). Soft-deleted lists
        are NEVER returned.

        By default `activeOnly=1` filters out lists with `active=false`.
        Pass `activeOnly=0` to include inactive lists too. Cursor
        pagination uses `?cursor=` and `?limit=` (cap 100).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: activeOnly
          required: false
          schema: { type: string, enum: ["0", "1", "true", "false"], default: "1" }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Price list list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:    { type: boolean, example: true }
                  count:      { type: integer }
                  hasMore:    { type: boolean }
                  nextCursor:
                    oneOf:
                      - { type: "null" }
                      - { type: string }
                  priceLists:
                    type: array
                    items: { $ref: "#/components/schemas/PriceList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/price-lists/{priceListId}:
    get:
      summary: Get a price list by id
      tags: ["Price list"]
      description: |
        Required scope: `price_lists_read`. Returns `404` (not `403`)
        on cross-store access — the API never reveals the existence of
        price lists in other stores. Soft-deleted lists are also `404`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/priceListId"
      responses:
        "200":
          description: Price list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:   { type: boolean, example: true }
                  priceList: { $ref: "#/components/schemas/PriceList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/price-lists/by-link/{linkId}:
    get:
      summary: Get a price list by linkId
      tags: ["Price list"]
      description: |
        Required scope: `price_lists_read`. Looks up the price list by
        its public-facing `linkId` slug — the same value that appears in
        `Page.priceListLinkId` and storefront URLs. The slug is
        case-insensitive (lowercased server-side before lookup).

        Always scoped to the calling key's store, so a slug that exists
        in another store but not in this one returns `404`. Soft-deleted
        lists are also `404`.

        `linkId` is enforced unique within a store on create/update
        (excluding soft-deleted lists), so this endpoint deterministically
        returns at most one result.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/priceListLinkId"
      responses:
        "200":
          description: Price list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:   { type: boolean, example: true }
                  priceList: { $ref: "#/components/schemas/PriceList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/accounts:
    get:
      summary: Search accounts by phone or email
      tags: [Account]
      description: |
        Provide exactly one of `phone` or `email`. Phone matching is strict
        equality on digits-only normalization (P0.1) — `+972-50…` and `0501…`
        are NOT equivalent because their digit sequences differ. Send the
        phone in the format the store recorded.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: phone
          schema: { type: string, maxLength: 30 }
        - in: query
          name: email
          schema: { type: string, format: email, maxLength: 254 }
      responses:
        "200":
          description: List of matching accounts (may be empty)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  accounts:
                    type: array
                    items: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      summary: Create a new account
      tags: [Account]
      description: |
        Same allow-list as Update. Unknown fields (typos, non-sensitive
        extras) are silently dropped and reported in `ignoredFields`.
        Platform-stamped identity, ownership, and soft-delete fields
        (notably the customer's `uid` and `createdAt`) are **rejected
        with `400`** if supplied — the error response names the
        specific rejected field. The platform stamps ownership,
        soft-delete state, audit timestamp, key attribution, and the
        customer's `uid` automatically.

        **Required fields:** `fullName` AND `email`.

        **Idempotency.** If the request `email` matches an existing
        non-deleted account on this store (case-insensitive), the
        endpoint returns HTTP **200** (not 201) with `existing: true`
        and the existing account instead of creating a duplicate.
        The check is best-effort: two simultaneous requests with the
        same email may both succeed (race window).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - { $ref: "#/components/schemas/AccountUpdate" }
                - type: object
                  required: [fullName, email]
      responses:
        "200":
          description: Existing account matched by email — no new doc was created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  existing: { type: boolean, example: true }
                  account: { $ref: "#/components/schemas/Account" }
                  accountId: { type: string }
                  message: { type: string }
        "201":
          description: Created account (or write-receipt if no `accounts_read` scope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  account: { $ref: "#/components/schemas/Account" }
                  accountId: { type: string }
                  changedFields:
                    type: array
                    items: { type: string }
                  ignoredFields:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/accounts/{accountId}:
    get:
      summary: Get one account
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/accountId"
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  account: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      summary: Update an account
      tags: [Account]
      description: |
        Required scope: `accounts_write`. Allow-list update — unknown
        fields are silently dropped and reported in `ignoredFields`.
        Platform-stamped identity, ownership, and soft-delete fields
        (notably `uid` and `createdAt`) are **rejected with `400`** if
        supplied — `uid` is immutable post-creation by design (every
        cart is bound to the customer through it). The error response
        names the specific rejected field.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/accountId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountUpdate" }
      responses:
        "200":
          description: Updated account
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  account: { $ref: "#/components/schemas/Account" }
                  ignoredFields:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
  /stores/{storeHandle}/customers/{uid}:
    get:
      summary: Get a customer by uid
      tags: [Account]
      description: |
        Preferred over `GET /accounts/{accountId}` when you already
        hold a customer `uid` (from a chat session, OAuth token, prior
        integration, etc.). Resolves the platform-wide uid to the
        customer's account row IN THIS STORE, returning both the
        per-store `accountId` (for downstream management calls) and
        the full `Account` body (same shape as the accountId-keyed
        endpoint).

        `404` when the uid has no account in this store — note that
        the customer may still have guest carts; check
        `GET /customers/{uid}/orders` for those.

        When the key also has `orders_read`, the response includes
        `inFulfillmentOrders` on the account (current in-flight orders).

        Required scope: `accounts_read`.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/uid"
      responses:
        "200":
          description: Resolved customer
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  uid: { type: string }
                  accountId:
                    type: string
                    description: The per-store account id; use this for management calls like `POST /accounts/{accountId}`.
                  account: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/customers/{uid}/orders:
    get:
      summary: List a customer's orders by uid
      tags: [Order]
      description: |
        Preferred over `GET /accounts/{accountId}/orders` when you
        already hold a customer `uid`. Same response shape, same
        pagination, same status filter — but skips the
        accountId-resolution hop and queries the underlying order
        store directly by the customer's stable identity.

        Use this when:
          - you have a uid from another context (chat, OAuth, your
            own database) and don't want to look up the per-store
            accountId first
          - the customer placed orders as a guest (no account row was
            ever created on this store — the accountId-keyed
            endpoint won't find them, but this one will)

        Returns `200` with `count: 0` when the uid has no orders in
        this store (no `404` — uid is platform-wide; the
        zero-orders-in-this-store case is a valid result, not an error).

        Required scopes: BOTH `accounts_read` AND `orders_read` (same
        gate as the accountId-keyed endpoint — order data leaks
        purchase history).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - $ref: "#/components/parameters/uid"
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [pending, in_fulfillment, completed, all]
            default: all
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          required: false
          schema: { type: string }
          description: Opaque cursor from the previous page's `nextCursor`.
      responses:
        "200":
          description: Page of orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  uid: { type: string }
                  status: { type: string, enum: [pending, in_fulfillment, completed, all] }
                  count: { type: integer }
                  hasMore: { type: boolean }
                  nextCursor:
                    type: string
                    nullable: true
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /stores/{storeHandle}/reports/range:
    get:
      summary: Range report (orders + products)
      tags: [Report]
      description: |
        Aggregates every finalized order in the requested window and
        emits per-order rows + per-product rollups + key figures.

        Required scope: **`reports_read`**. This scope is granted
        ONLY to the store's `manager` role — `packer`, `carrier`,
        `operations`, `support`, etc. do NOT receive it, even though
        they hold `orders_read`. The platform re-checks the caller's
        active roles on the store on every request, so a user whose
        manager role was removed loses access within ~30 seconds
        (`403`).

        Hard guardrails:

        - Maximum window: **100 days**.
        - Maximum orders processed per call: **5000**. The handler
          counts orders before running the aggregation; if the count
          exceeds the cap, the call returns `400` immediately and no
          data is computed.

        `fetchBy` selects the window's anchor field:

        - `order_date` (default) — orders whose checkout finalized
          inside the window.
        - `delivery_date` — orders whose delivery slot falls inside
          the window. Orders without a slot are skipped.

        Cost-plan stores additionally see `keyFigures.totalCosts` and
        per-row `totalCost` / `unitCost` / `totalSumWithoutCost` /
        `totalOrdersWithoutCost`. Stores on other plans see those
        fields omitted entirely (not zeroed).
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: startTime
          required: true
          schema: { type: string, format: date-time }
          description: ISO 8601 UTC start of the window (exclusive).
        - in: query
          name: endTime
          required: true
          schema: { type: string, format: date-time }
          description: ISO 8601 UTC end of the window (inclusive). Must be after `startTime`.
        - in: query
          name: fetchBy
          schema:
            type: string
            enum: [order_date, delivery_date]
            default: order_date
      responses:
        "200":
          description: Range report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RangeReport" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /stores/{storeHandle}/reports/transactions:
    get:
      summary: Transactions report
      tags: [Report]
      description: |
        Lists every completed payment transaction in the requested
        window, with synthetic dedup signals: `isDuplicate` (when the
        approval number collides with an earlier transaction on the
        same order) and `isDuplicateInvoice` (when the invoice link is
        repeated).

        Required scope: `transactions_read`.

        Hard guardrails:

        - Maximum window: **100 days**.
        - Maximum transactions per call: **10000**. The handler counts
          transactions before serialization; over-cap calls return
          `400` immediately.

        Internally surfaced flags `creditTransaction`, `ignoreInBalance`,
        and `documentType` carry through unchanged so accounting
        integrations can apply their own dedup / sign / classification
        rules.
      parameters:
        - $ref: "#/components/parameters/storeHandle"
        - in: query
          name: startTime
          required: true
          schema: { type: string, format: date-time }
        - in: query
          name: endTime
          required: true
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Transactions report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransactionsReport" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
  parameters:
    storeHandle:
      in: path
      name: storeHandle
      required: true
      schema: { type: string, pattern: "^[a-zA-Z0-9-._]+$" }
    accountId:
      in: path
      name: accountId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    uid:
      in: path
      name: uid
      required: true
      description: |
        A platform-wide customer identity. Stable across stores and
        across all of the customer's orders. Typically 28 chars for
        registered customers, longer for guest identities. Get one
        from `account.uid` on any `/accounts/{accountId}` response,
        or from `order.uid` on any order.
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{20,128}$" }
    productId:
      in: path
      name: productId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    orderId:
      in: path
      name: orderId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    contentId:
      in: path
      name: contentId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    pageId:
      in: path
      name: pageId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    priceListId:
      in: path
      name: priceListId
      required: true
      schema: { type: string, pattern: "^[A-Za-z0-9_-]{6,64}$" }
    priceListLinkId:
      in: path
      name: linkId
      required: true
      description: |
        Public-facing slug for the price list, lowercased. Same value
        that appears in `Page.priceListLinkId` and storefront URLs.
      schema: { type: string, pattern: "^[a-z0-9._-]{1,64}$" }
  schemas:
    Account:
      type: object
      properties:
        id: { type: string }
        fullName:
          type: string
          description: |
            Customer full name. The API exposes ONLY `fullName` — there
            is no `firstName` / `lastName` split on the wire.
        email: { type: string }
        phoneNumber: { type: string }
        address: { type: string }
        city:
          type: string
          description: |
            Structured address fields. Spread alongside `address` (no nested
            `addressDetails` sub-object). Empty strings when missing on the
            source doc.
        streetNumber: { type: string }
        entrance: { type: string }
        code: { type: string }
        apartment: { type: string }
        deliveryComment: { type: string }
        geolocation:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/Geolocation" }
        deliveryOption:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/DeliveryOptionRef" }
        paymentMethod: { type: string }
        billingName: { type: string }
        customerTaxId: { type: string }
        discountType:
          oneOf:
            - { type: "null" }
            - { type: string, enum: [amount, percent] }
        discount: { type: number }
        freeDelivery: { type: boolean }
        freePickup: { type: boolean }
        subscribed: { type: boolean }
        orderComments: { type: string }
        reuseComment: { type: boolean }
        additionalPaymentOptions: { type: array, items: {} }
        hasSavedCreditCard: { type: boolean }
        hasManagementNotes: { type: boolean }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        accountSums:
          $ref: "#/components/schemas/AccountSums"
        orders:
          type: array
          description: |
            Lightweight per-order summary list, newest-first by `finalizedAt`.
            Built from the rolling `account.orders` map maintained by the
            platform. Use this to know which orders the customer placed and
            when. For full order bodies, see the orders endpoint OR hold an
            `orders_read` scope alongside `accounts_read` to receive the
            `inFulfillmentOrders` field below.
          items: { $ref: "#/components/schemas/AccountOrderEntry" }
        inFulfillmentOrders:
          type: array
          description: |
            Full bodies of every order this customer has currently in the
            fulfillment pipeline (finalized=true, closed=false). PRESENT
            ONLY when the API key holds BOTH `accounts_read` AND
            `orders_read`. Without `orders_read`, the field is ABSENT (not
            empty) so callers can distinguish "no permission" from "no
            in-flight orders".
          items: { $ref: "#/components/schemas/Order" }
    AccountSums:
      type: object
      description: |
        Rolling lifetime aggregates the platform maintains per account.
        Sum fields are in the store's currency. Internal-only counters
        are intentionally NOT exposed here.
      properties:
        ordersCount: { type: number }
        totalOrders: { type: number, description: "Sum of order totals (lifetime)" }
        totalDeliveries: { type: number }
        totalMissing: { type: number }
        totalSumDue: { type: number }
        totalVatDue: { type: number }
        averageOrder: { type: number }
        firstOrderAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        lastOrderAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    AccountOrderEntry:
      type: object
      properties:
        id: { type: string, description: "Order identifier" }
        finalizedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        closed: { type: boolean }
        totalSum:
          oneOf:
            - { type: "null" }
            - { type: number }
        sumDue:
          oneOf:
            - { type: "null" }
            - { type: number }
    OrderUpdate:
      type: object
      additionalProperties: false
      properties:
        orderComments: { type: string, maxLength: 1000 }
        reuseComment: { type: boolean }
        touched: { type: boolean }
    OrderCreateItem:
      type: object
      additionalProperties: false
      required: [productId, quantity]
      properties:
        productId:
          type: string
          pattern: "^[A-Za-z0-9_-]{6,64}$"
        quantity:
          type: number
          exclusiveMinimum: 0
        units:
          type: string
          minLength: 1
          maxLength: 60
          description: |
            Optional. Defaults to the product's `prices.defaultUnits`.
            The selected unit must have a price defined on the product.
    OrderCreateCustomer:
      type: object
      additionalProperties: false
      properties:
        fullName:   { type: string, maxLength: 120 }
        email:      { type: string, format: email, maxLength: 200 }
        phoneNumber: { type: string, maxLength: 40 }
        address:    { type: string, maxLength: 300 }
    OrderCreateDeliveryOption:
      type: object
      additionalProperties: false
      required: [id, method]
      properties:
        id:     { type: string, minLength: 1, maxLength: 200 }
        method: { type: string, enum: [pickup, delivery] }
        text:   { type: string, maxLength: 200 }
        cost:   { type: number, minimum: 0 }
        carriers:
          type: array
          maxItems: 10
          items: { type: string, minLength: 1, maxLength: 64 }
    OrderCreate:
      type: object
      additionalProperties: false
      required: [items]
      description: |
        Customer attribution — supply exactly one identity primitive:

        - **`uid`** (preferred when available) — platform-wide
          customer identity. Works for both existing accounts AND
          guest orders. If the uid has an account in this store, the
          order's customer details (fullName, email, phoneNumber,
          address) are populated from that account; otherwise
          `customer` MUST also be supplied so the order has its
          customer details.
        - **`accountId`** — per-store account identifier. The order's
          customer details come from the account. Per-field overrides
          via `customer` are honored.
        - **`customer`** alone — pure-guest path; a synthetic
          owner identity is generated for the order.

        `accountId` and `uid` are mutually exclusive (400 if both
        supplied — they identify the same customer through different
        keys). `mode=finalize` additionally requires `deliveryOption`
        and a resolvable customer email.
      properties:
        mode:
          type: string
          enum: [pending, finalize]
          default: pending
        items:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: "#/components/schemas/OrderCreateItem" }
        accountId:
          type: string
          pattern: "^[A-Za-z0-9_-]{6,64}$"
          description: |
            Per-store account id. Mutually exclusive with `uid`.
        uid:
          type: string
          pattern: "^[A-Za-z0-9_-]{20,128}$"
          description: |
            Platform-wide customer identity. Mutually exclusive with
            `accountId`. When the uid has no account in this store,
            `customer` is REQUIRED so the new order has details.
        customer:   { $ref: "#/components/schemas/OrderCreateCustomer" }
        deliveryOption: { $ref: "#/components/schemas/OrderCreateDeliveryOption" }
        paymentMethod:
          type: string
          enum:
            - cash
            - bank_transfer
            - bit
            - paybox
            - pepper
            - check
            - credit_card_by_phone
            - other
            - balance
          default: cash
          description: |
            Payment method recorded on the order. **`credit_card` is
            rejected** — the API does not drive the card payment flow
            (use the storefront checkout for card payments). For other
            unlisted values: pass `other` and capture the detail in
            `orderComments`.
        orderComments: { type: string, maxLength: 1000 }
        priceListLinkId:
          type: string
          pattern: "^[A-Za-z0-9_-]{1,64}$"
          description: |
            Price the order against the customer's price list (matched by
            the list's `linkId`). Items are filtered to the list's
            inclusion rules (table-mode allow-list / rules-mode by
            category / attribute) — an item not in the list is rejected
            with `400` — and prices use the list's overrides. `400` if no
            active price list matches the linkId.
        sendEmail:
          type: boolean
          default: true
          description: |
            Only meaningful when `mode=finalize`. Set to `false` to
            suppress the customer order-confirmation email.
    Inventory:
      type: object
      additionalProperties: false
      properties:
        productId: { type: string }
        tracked:
          type: boolean
          description: True only when `track=true` AND `qty` is finite.
        qty:
          oneOf:
            - { type: "null" }
            - { type: number }
        markOutOfStock: { type: boolean }
        alertsThreshold: { type: number, minimum: 0 }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    InventoryUpdate:
      type: object
      additionalProperties: false
      required: [delta]
      description: |
        Per-product inventory write. `delta` is REQUIRED and is the
        signed change applied to the current `qty`. The API does NOT
        accept absolute `qty` writes (passing `qty` is rejected with a
        400) — this is what makes concurrent writes safe: every caller
        composes its delta against the current value inside a
        transaction, so no caller's update can be silently overwritten
        by another caller's stale read.
      properties:
        delta:
          type: number
          description: |
            Signed change applied to the current qty. Positive adds
            stock, negative removes it. The transaction rejects with
            400 if `currentQty + delta < 0`. To set absolute qty: GET
            the current qty, then POST `delta = target - current`.
        track:           { type: boolean }
        markOutOfStock:
          type: boolean
          description: |
            When set, this write also flips the product's `outOfStock`
            flag to `(finalQty === 0)`. When false (or omitted) AND
            `track:true` AND finalQty becomes 0, the product's
            `forSale` flag is flipped to false instead. Only the
            specific flag field is written; other product fields are
            never touched.
        alertsThreshold: { type: number, minimum: 0 }
    AccountUpdate:
      type: object
      additionalProperties: false
      properties:
        fullName: { type: string, minLength: 1, maxLength: 120 }
        email: { type: string, format: email, maxLength: 254 }
        phoneNumber: { type: string, maxLength: 30 }
        address: { type: string, maxLength: 500 }
        billingName: { type: string, maxLength: 120 }
        customerTaxId: { type: string, maxLength: 20 }
        orderComments: { type: string, maxLength: 1000 }
        reuseComment: { type: boolean }
        freeDelivery: { type: boolean }
        freePickup: { type: boolean }
        subscribed: { type: boolean }
        discount: { type: number, minimum: 0, maximum: 100000 }
        discountType: { type: string, enum: [amount, percent] }
    StoreUpdate:
      type: object
      description: |
        Partial update of the store's public profile. All fields optional —
        send only what you want to change. Nested maps (`contact`,
        `mainContact`, `links`) are merged per-field. Unknown keys are
        ignored (echoed back in `ignoredFields`), not rejected. A `links`
        channel is cleared by sending an empty string.
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000 }
        contact:
          type: object
          properties:
            phoneNumber: { type: string, maxLength: 40 }
        mainContact:
          type: object
          properties:
            firstName: { type: string, maxLength: 100 }
            lastName: { type: string, maxLength: 100 }
            whatsapp: { type: string, maxLength: 40 }
        links:
          type: object
          properties:
            website: { type: string, maxLength: 500 }
            fb: { type: string, maxLength: 500 }
            instagram: { type: string, maxLength: 500 }
            youtube: { type: string, maxLength: 500 }
            tiktok: { type: string, maxLength: 500 }
            twitter: { type: string, maxLength: 500 }
    StoreImageUpload:
      type: object
      required: [kind, contentType, dataBase64]
      properties:
        kind: { type: string, enum: [logo, cover] }
        contentType: { type: string, enum: ["image/png", "image/jpeg", "image/webp", "image/gif"] }
        dataBase64:
          type: string
          description: Base64-encoded image bytes (decoded size ≤ 10 MiB).
        filename: { type: string, maxLength: 120 }
    Store:
      type: object
      properties:
        id: { type: string }
        storeHandle: { type: string }
        name: { type: string }
        description: { type: string }
        logoUrl: { type: string }
        coverUrl: { type: string }
        contact:
          type: object
          properties:
            phoneNumber: { type: string }
            email: { type: string }
            address: { type: string }
            addressDetails:
              oneOf:
                - { type: "null" }
                - type: object
                  properties:
                    city: { type: string }
                    country: { type: string }
                    formattedAddress: { type: string }
                    geolocation:
                      oneOf:
                        - { type: "null" }
                        - { type: object, additionalProperties: true }
        mainContact:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                firstName: { type: string }
                lastName: { type: string }
                whatsapp: { type: string }
        links:
          type: object
          description: |
            Public social / website links the store published. Keys are
            optional; empty values are dropped.
          properties:
            website: { type: string }
            fb: { type: string }
            instagram: { type: string }
            youtube: { type: string }
            tiktok: { type: string }
            twitter: { type: string }
        currency: { type: string, example: ILS }
        timezone: { type: string, example: Asia/Jerusalem }
        language: { type: string, example: he }
        isActive: { type: boolean }
        acceptsOrders: { type: boolean }
        minimumOrder:
          oneOf:
            - { type: "null" }
            - { type: number }
        categories:
          type: array
          description: |
            Plain category-name strings. Internal markers are stripped
            before serialization.
          items: { type: string }
        tags:
          type: array
          items: { type: string }
        vatFreeSeller:
          type: boolean
          description: |
            `true` when the entire store operates VAT-exempt ("עוסק פטור"
            in Israeli tax terminology). Tax-reconciliation integrations
            must check this BEFORE assuming VAT applies to any order from
            this store.

            Flipping this flag does NOT retroactively re-stamp historical
            finalized orders — each order's `vatRate` is the snapshot at
            the time it was finalized.
        vatDateBasis:
          type: string
          enum: ["", cash, accrual]
          description: |
            Operational rule that picks which event anchors the VAT rate
            on a cart:
              - `cash`     — the rate in effect at payment time
              - `accrual`  — the rate in effect at delivery time
              - `""`       — store has not set a preference (platform
                             default applies)

            Empty string is returned when the store has not configured
            the setting (rather than `null`) so integrations don't need
            to branch on type.
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    DeliveryOption:
      type: object
      properties:
        id: { type: string }
        text: { type: string }
        method: { type: string, enum: [delivery, pickup] }
        active: { type: boolean }
        description: { type: string }
        minOrderForFreeDelivery:
          oneOf:
            - { type: "null" }
            - { type: number }
        minimumOrder:
          description: Per-option minimum cart sum to offer this option (`cliffToShow`); null when none.
          oneOf:
            - { type: "null" }
            - { type: number }
        cost:
          oneOf:
            - { type: "null" }
            - { type: number }
        pickupAddress: { type: string }
        geolocation:
          description: (pickup only) Geolocation of the pickup point.
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/Geolocation" }
        polygon:
          type: array
          description: |
            (delivery only) The delivery zone as a list of `{lat, lng}`
            points. ≥3 points form a real shape; malformed entries are
            dropped silently.
          items: { $ref: "#/components/schemas/Geolocation" }
    OrderItem:
      type: object
      properties:
        productId: { type: string }
        name: { type: string }
        quantity:
          oneOf:
            - { type: "null" }
            - { type: number }
        units: { type: string }
        unitPrice:
          oneOf:
            - { type: "null" }
            - { type: number }
        totalPrice:
          oneOf:
            - { type: "null" }
            - { type: number }
        vatFree:
          type: boolean
          description: |
            Snapshot of the product's `vatFree` at the time this line
            was added to the cart. `true` means this specific line was
            treated as VAT-exempt; subsequent changes to the product's
            current `vatFree` flag do NOT mutate historical orders.
        missing:
          type: boolean
          description: |
            True if the merchant has flagged this line as unfulfillable. The
            line stays on the order for audit but is not delivered.
        comment: { type: string }
        specialOffers:
          type: array
          description: >-
            Present only if saved on the line. The discount/offer snapshot the
            checkout pipeline persisted for THIS line — returned exactly as
            stored (NO active/expiry filtering; an order is a historical
            record), so the shape reflects what was applied at order time.
          items: { type: object }
        discounts:
          type: object
          description: >-
            Present only if saved on the line. Quantity-discount snapshot as
            persisted at order time (no active/expiry filtering).
    Delivery:
      type: object
      description: |
        One row of the in-fulfillment delivery list, shaped to match
        the store admin's CSV export — same columns, camelCase keys,
        plus `orderId` and `carrierId` so an integration can chain
        follow-up calls.
      properties:
        no:
          type: integer
          description: 1-indexed position in the response (matches CSV "מס'" column).
        orderId: { type: string }
        carrierId:
          type: string
          description: |
            Empty string for unassigned drops (`carrierId=none` /
            `carrierId=none-pickup` filters surface these).
        carrier:
          type: string
          description: |
            Resolved carrier name. Falls back to the raw carrier id when
            no name is on file. Empty string for unassigned drops.
        fullName: { type: string }
        firstOrder: { type: boolean }
        phoneNumber: { type: string }
        paymentMethod: { type: string }
        method:
          type: string
          enum: [delivery, pickup, ""]
          description: Raw enum (use `methodLabel` for the Hebrew display string).
        methodLabel:
          type: string
          description: |
            Hebrew label that matches the CSV "סוג" column verbatim
            (`משלוח` for delivery, `איסוף עצמי` for pickup, `""` otherwise).
        deliveryOption: { type: string, description: "deliveryOption.text — display name of the chosen zone or pickup point." }
        deliveryDate:
          type: string
          description: |
            `DD/MM/YYYY` (slash-separated) — mirrors the dot→slash transform
            the client applies on export. Empty when no date is set.
        city: { type: string }
        address:
          type: string
          description: |
            "{street}, {streetNumber}" for delivery orders. Empty for pickup
            orders (which use the pickup point's address embedded in the
            deliveryOption itself, exposed via `deliveryOption` text).
        lat:
          oneOf:
            - { type: "null" }
            - { type: number }
        lng:
          oneOf:
            - { type: "null" }
            - { type: number }
        floor: { type: string }
        apartment: { type: string }
        addressDetails:
          type: object
          properties:
            entrance: { type: string }
            code: { type: string }
            deliveryComment: { type: string }
        noOfBoxes:
          oneOf:
            - { type: number }
            - { type: string, enum: ["NA"] }
          description: |
            Total number of labels/boxes for the delivery, computed from
            each line's `sizeInBox × quantity` (with `ownBox` items
            counted by quantity instead of by size). String `"NA"` when
            no count could be computed.
        deliveryComment: { type: string }
        comments: { type: string }
        managerComments: { type: string }
        totalSum:
          oneOf:
            - { type: "null" }
            - { type: number }
        deliveredAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        finalizedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    DeliveriesOrderUpdate:
      type: object
      additionalProperties: false
      required: [orderIds]
      description: |
        Body for `POST /deliveries/{carrierId}/order`. The provided
        `orderIds` array REPLACES the existing list for this carrier
        (no append/merge semantics). Optional route fields are
        strictly allow-listed.
      properties:
        orderIds:
          type: array
          maxItems: 500
          items:
            type: string
            pattern: "^[A-Za-z0-9_-]{6,64}$"
          description: |
            Ordered cart ids. The full list to persist on this
            carrier's route. Must contain no duplicates. Each id
            must reference an in-fulfillment order in this store
            partitioned under the same `carrierId`.
        polyline:
          type: string
          maxLength: 50000
          description: |
            Encoded polyline string for the optimized route. Stored
            verbatim under `routes.{carrierId}.polyline` and read by
            map renderers. Capped at 50000 chars.
        routeStats:
          type: object
          description: |
            Numeric route-summary stats. Only allow-listed keys are
            persisted; unknown keys are surfaced in
            `ignoredRouteStatsKeys`. Each value must be a finite
            number.
          properties:
            totalDistance: { type: number, description: "Meters." }
            totalDuration: { type: number, description: "Seconds." }
            totalDurationInTraffic: { type: number, description: "Seconds (traffic-adjusted)." }
            totalKm: { type: number, description: "Kilometers." }
            totalTime: { type: number, description: "Minutes." }
            totalTimeInTraffic: { type: number, description: "Minutes (traffic-adjusted)." }
            totalArealkm: { type: number, description: "Aerial / great-circle distance, kilometers." }
        origin:
          $ref: "#/components/schemas/RoutePoint"
        destination:
          $ref: "#/components/schemas/RoutePoint"
    RoutePoint:
      type: object
      additionalProperties: false
      required: [geo]
      description: |
        A start/end point on a planned route. `geo` lat/lng are
        validated against [-90, 90] / [-180, 180].
      properties:
        geo:
          type: object
          required: [lat, lng]
          properties:
            lat: { type: number, minimum: -90, maximum: 90 }
            lng: { type: number, minimum: -180, maximum: 180 }
        formattedAddress:
          type: string
          maxLength: 300
    Order:
      type: object
      properties:
        id: { type: string }
        status:
          type: string
          enum: [pending, in_fulfillment, completed, canceled]
          description: |
            `canceled` means the order was finalized at some point and
            has since been canceled through the platform's cancel
            pipeline. A never-finalized cart that was simply discarded
            reports as `pending` — never `canceled`.
        finalized: { type: boolean }
        closed: { type: boolean }
        delivered: { type: boolean }
        paid: { type: boolean }
        canceled:
          type: boolean
          description: |
            True iff the order was finalized at some point AND has since
            been canceled through the platform's cancel pipeline.
        firstOrder: { type: boolean }
        sum:
          oneOf:
            - { type: "null" }
            - { type: number }
        vatRate:
          description: |
            The VAT rate snapshotted at finalize time — the rate every
            per-item price on this order was computed against. Usually
            stored as a decimal (e.g. `0.18`), but legacy orders may
            carry a percentage value (e.g. `17`); the wire shape is
            pass-through. Pair with `vatBy` and `vatDate` for full
            provenance.
          oneOf:
            - { type: "null" }
            - { type: number }
        vatBy:
          type: string
          enum: ["", paymentTime, deliveryTime, closedTime, nowTime, vatFreeSeller, manager]
          description: |
            Operational rule that picked the rate stored in `vatRate`:
              - `paymentTime`    — rate in effect when payment was taken
              - `deliveryTime`   — rate in effect on the delivery date
              - `closedTime`     — rate in effect when the order closed
              - `nowTime`        — rate in effect when the cart was finalized
              - `vatFreeSeller`  — the entire store is VAT-exempt
              - `manager`        — a merchant explicitly overrode the rate
                                   on this cart
              - `""`             — never stamped (pre-finalize / legacy)

            Together with `vatDate` this lets an auditor explain WHY a
            particular rate applied without inspecting store config.
        vatDate:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
          description: |
            The reference timestamp the rule named in `vatBy` consulted
            to pick `vatRate`. `null` on pending carts that haven't been
            finalized yet.
        sums:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/OrderSums" }
          description: |
            mypips's point of truth for the order's totals. Sourced from
            the cached order totals when present; otherwise recomputed
            on the fly. Use this sub-object for accountant/integration
            reconciliation rather than recomputing from `items`. `null`
            only for pending carts with no items / no precomputed
            totals.
        itemCount: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/OrderItem" }
        deliveryOption:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/DeliveryOptionPublic" }
        deliverToLocationGeo:
          description: |
            "Where is this order being delivered TO." Resolved from
            `deliveryOption.geolocation` when `method=pickup`, or from
            `customer.geolocation` when `method=delivery`. `null` when no
            deliveryOption is set or the source data is missing.
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/Geolocation" }
        deliverToLocationAddress:
          type: string
          description: |
            "Where is this order being delivered TO." Resolved from the
            chosen pickup point's address when `method=pickup`, or from
            the customer's full address (`address` +
            `addressDetails.street_number` + `city`) when
            `method=delivery`. Empty string when missing.
        carrier: { type: string }
        carrierName: { type: string }
        paymentMethod: { type: string }
        freeDelivery: { type: boolean }
        freePickup: { type: boolean }
        discount:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                type: { type: string, enum: [amount, percent, ""] }
                value:
                  oneOf:
                    - { type: "null" }
                    - { type: number }
                comment: { type: string }
        promoCodesUsed:
          type: array
          items: { type: string }
        priceListId:
          type: string
          description: |
            Id of the price list the order was placed against, or empty
            string when the order was not priced against a list.
        priceListLinkId:
          type: string
          description: |
            Public link identifier of the price list the order was placed
            against (the same value accepted as `priceListLinkId` when
            creating an order); empty string when none.
        customer:
          type: object
          description: |
            Customer the order is for. Structured address fields (`city`,
            `streetNumber`, `entrance`, `code`, `apartment`,
            `deliveryComment`, `geolocation`) are spread alongside the
            free-form `address` string and are absent when the cart has no
            `addressDetails`.
          properties:
            accountId: { type: string }
            fullName:
              type: string
              description: |
                Customer full name. The API never exposes a `firstName`
                / `lastName` split; legacy fields are normalised to
                `fullName` server-side.
            email: { type: string }
            phoneNumber: { type: string }
            address: { type: string }
            city: { type: string }
            streetNumber: { type: string }
            entrance: { type: string }
            code: { type: string }
            apartment: { type: string }
            deliveryComment: { type: string }
            geolocation:
              oneOf:
                - { type: "null" }
                - { $ref: "#/components/schemas/Geolocation" }
        comments: { type: string }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        finalizedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        canceledAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    Geolocation:
      type: object
      description: |
        Latitude / longitude pair. `null` when the source doc has no
        geolocation OR when either coordinate is missing / non-numeric
        (we never emit a half-populated point).
      required: [lat, lng]
      properties:
        lat: { type: number }
        lng: { type: number }
    DeliveryOptionRef:
      type: object
      description: |
        Slim reference to a delivery option as stored on an account /
        customer (their last-used choice) — identity only. Pricing,
        dates, addresses and zone geometry are order-time / live-options
        data and are not part of this account-level reference.
      properties:
        id: { type: string }
        text: { type: string }
        method: { type: string, enum: [delivery, pickup] }
    DeliveryOptionPublic:
      type: object
      description: |
        Public-safe slice of a delivery / pickup option as it appears on a
        cart, account, or order. The shape is **conditional on `method`**:
          - `pickup`   options carry the pickup point's `address` +
            `geolocation` (no `polygon`).
          - `delivery` options carry the delivery zone `polygon` (no
            `address` / `geolocation` — those refer to the customer's
            address and live on `customer` / `deliverToLocation*` instead).
        Internal scheduling — `days`, `dates`, `carriers`, `distributers`,
        `checkoutDeliverySum` (manager override) — is filtered out.
      properties:
        id: { type: string }
        text: { type: string }
        display: { type: string }
        method: { type: string, enum: [delivery, pickup, ""] }
        cost:
          oneOf:
            - { type: "null" }
            - { type: number }
        description: { type: string }
        deliveryDate: { type: string }
        freeAbove:
          oneOf:
            - { type: "null" }
            - { type: number }
        address:
          type: string
          description: |
            (pickup only) Pickup point address. Falls back to
            `pickupAddress` on the source doc when `address` is empty.
        geolocation:
          description: (pickup only) Geolocation of the pickup point.
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/Geolocation" }
        polygon:
          type: array
          description: |
            (delivery only) The delivery zone as a list of `{lat, lng}`
            points. ≥3 points form a real shape; malformed entries are
            dropped silently.
          items: { $ref: "#/components/schemas/Geolocation" }
    OrderSums:
      type: object
      description: |
        Calculated totals + invoice-line breakdown for the order. All
        numeric fields are always present (default 0); `cartDiscount` is
        `null` when no manual cart-level discount applies; `promoCode` is
        the empty string when no promo applies. `invoice_items` is `null`
        when only cached totals were available (no per-line breakdown).
        String fields are truncated to 200 chars; `queryParams` is
        capped at 30 keys, each value at 200 chars.
      properties:
        totalSum: { type: number, description: "Final total the customer pays." }
        totalVat: { type: number, description: "VAT included in totalSum." }
        vatRate: { type: number, description: "VAT rate used (e.g. 0.18)." }
        totalVatFreeSum: { type: number, description: "Sum of VAT-exempt items." }
        totalDiscount: { type: number, description: "Total discount applied (cart + promo)." }
        totalStoreDiscount: { type: number, description: "Sum of store-discount lines (subset of totalDiscount)." }
        deliverySum: { type: number, description: "Delivery line item charge." }
        originalDeliverySum: { type: number, description: "Delivery price before any free-delivery override." }
        totalMissing: { type: number, description: "Value of items the merchant marked unfulfillable." }
        noOfItemsInCart: { type: number, description: "Count of distinct line items." }
        promoCode: { type: string, description: "The promo code applied (empty if none)." }
        cartDiscount:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                type: { type: string, enum: [amount, percent, ""] }
                value:
                  oneOf:
                    - { type: "null" }
                    - { type: number }
                comment: { type: string }
        invoice_items:
          description: |
            Per-line invoice breakdown (items, delivery, discount lines).
            `null` when the cart's per-line detail isn't available (e.g.
            we fell back to cached totals on a malformed legacy cart).
            **Snake_case on the wire by request** — accounting /
            invoice integrations consume this field directly.
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/InvoiceItems" }
        storeDiscountLines:
          type: array
          description: |
            Per-applied store-wide discount: label, sublabel, description,
            amount, color. Internal config (scope rules, supplier
            allow-lists, manager notes) is filtered out.
          items: { $ref: "#/components/schemas/StoreDiscountLine" }
        scheduledOrderParent:
          type: string
          description: |
            Parent cart id when this is a scheduled-order child. Empty
            string for one-off orders. Truncated to 64 chars.
        priceListId:
          type: string
          description: |
            Active price list id. Empty when the cart used the public
            price list. Truncated to 64 chars.
        priceListLinkId:
          type: string
          description: |
            Per-customer link id under the price list (e.g. for private
            invitations). Empty when not applicable. Truncated to 64 chars.
        referrer:
          type: string
          description: |
            Normalized traffic source category — `google`, `instagram`,
            `mypips`, `manager_page`, etc. Already de-PII'd by a
            normalization step before storage. Truncated to 200 chars.
        queryParams:
          type: object
          description: |
            UTM / tracked params from the finalize page (utm_source,
            utm_campaign, utm_medium, …). Capped at 30 keys; each value
            truncated to 200 chars.
          additionalProperties: { type: string }
    InvoiceItems:
      type: object
      description: |
        Per-line invoice breakdown. Each line carries `description`,
        `quantity`, `price`, `itemTotalPrice`, `isVatItem`, `itemVat`,
        `itemTotalVat` — a shape suitable for downstream invoicing /
        accounting integrations.
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/InvoiceLine" }
        delivery:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/InvoiceLine" }
        discount:
          type: array
          items: { $ref: "#/components/schemas/InvoiceLine" }
        cartTotalVatFreeSum: { type: number }
        cartTotalVatIncludingSum: { type: number }
        cartTotalVat: { type: number }
        noOfItemsInCart: { type: number }
    InvoiceLine:
      type: object
      properties:
        description: { type: string, description: "Line label, truncated to 200 chars." }
        quantity: { type: number }
        price: { type: number }
        itemTotalPrice: { type: number }
        isVatItem: { type: boolean }
        itemVat: { type: number }
        itemTotalVat: { type: number }
    StoreDiscountLine:
      type: object
      properties:
        id: { type: string, description: "Discount config id, truncated to 64 chars." }
        type: { type: string, enum: [storeDiscount, ""] }
        label: { type: string, description: "Primary label, truncated to 200 chars." }
        sublabel: { type: string }
        description: { type: string }
        amount: { type: number, description: "Amount deducted from the order subtotal." }
        color: { type: string, description: "Display color hint, truncated to 32 chars." }
    Supplier:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        displayName: { type: string }
        personOfContact: { type: string }
        phoneNumber: { type: string }
        email: { type: string }
        address: { type: string }
        comment: { type: string }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    SupplierCreate:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        displayName: { type: string, maxLength: 200 }
        personOfContact: { type: string, maxLength: 200 }
        phoneNumber: { type: string, maxLength: 40 }
        email:
          type: string
          maxLength: 200
          description: Empty string clears; otherwise must look like user@host.tld.
        address: { type: string, maxLength: 300 }
        comment: { type: string, maxLength: 2000 }
    SupplierUpdate:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        displayName: { type: string, maxLength: 200 }
        personOfContact: { type: string, maxLength: 200 }
        phoneNumber: { type: string, maxLength: 40 }
        email: { type: string, maxLength: 200 }
        address: { type: string, maxLength: 300 }
        comment: { type: string, maxLength: 2000 }
    SupplierWriteReceipt:
      type: object
      properties:
        success: { type: boolean }
        supplierId: { type: string }
        changedFields:
          type: array
          items: { type: string }
        ignoredFields:
          type: array
          items: { type: string }
    Product:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        sku: { type: string }
        category: { type: string }
        supplierId:
          type: string
          description: |
            Reference to the supplier that vends this product. Required on
            writes; readable so callers can verify what they wrote.
        supplierName:
          type: string
          maxLength: 200
          description: |
            Caller-controlled supplier label. Settable on
            create/update like any other product field. The server never
            auto-derives this from the supplier, even when `supplierId`
            is being changed.

            Update semantics:
              - omitted from the body → field is left untouched
              - explicit `""` (empty string) → saved as `""`
              - any other string → saved verbatim, regardless of what the
                referenced supplier's `name`/`displayName` is

            Create semantics:
              - omitted → defaults to `""`
              - otherwise as above
        type: { type: string, enum: [regular, composite, bundle, content] }
        images:
          type: array
          items: { type: string, format: uri }
        coverImage: { type: string }
        prices:
          type: object
          properties:
            defaultUnits: { type: string }
            units:
              type: array
              items:
                type: object
                properties:
                  unit: { type: string }
                  price: { type: number }
        discounts:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                quantity:
                  type: array
                  items:
                    type: object
                    properties:
                      quantity: { type: number }
                      price: { type: number }
          description: >-
            Quantity-break discounts. Returned only while ACTIVE — once the
            discount's configured end date has passed this is `null`.
        specialOffers:
          oneOf:
            - { type: "null" }
            - type: array
              items:
                type: object
                properties:
                  id:           { type: string }
                  discountType: { type: string, enum: [buy_x_for_y, buy_x_get_gift, buy_x_get_self_free, buy_x_get_next_for_y_discount] }
                  quantityX:    { type: number, nullable: true }
                  priceY:       { type: number, nullable: true }
                  quantityY:    { type: number, nullable: true }
                  discountY:    { type: number, nullable: true }
                  productY:     { type: string }
                  label:        { type: string }
                  color:        { type: string }
                  discountPeriod: { type: string, description: "Offer end date (ISO)." }
          description: >-
            Promotional offers on the product. Only ACTIVE offers are returned
            — any whose `discountPeriod` has passed is omitted; `null` when
            none are active. Type-specific fields (`quantityX`/`priceY`/…)
            depend on `discountType`.
        attributes:
          type: array
          description: >-
            Product attribute tags as plain strings (e.g. "organic",
            "gluten-free"). The storefront turns each into an
            attribute-derived category filter.
          items: { type: string }
        inStock:
          type: boolean
          description: Supplier-active gate. `false` hides the product entirely. Independent of `outOfStock`.
        outOfStock:
          type: boolean
          description: |
            "Show on storefront but mark לא זמין / unavailable" hint —
            product still renders subject to `forSale` / `inStock`, but
            cannot be ordered. Independent of `inStock`. Never used as
            a filter on `GET /products` or `GET /products/{id}`.
        forSale:
          type: boolean
          description: Storefront-visibility gate for end customers. `false` hides from search/get unless `includeAll=true`.
        vatFree:
          type: boolean
          description: |
            `true` when this product is VAT-exempt at the line-item
            level (overrides the platform default for THIS product
            only). Tax-reconciliation integrations need this to compute
            per-line VAT contributions correctly. When the entire store
            operates VAT-exempt, see `Store.vatFreeSeller` — that takes
            precedence and zeroes out VAT on every item regardless of
            the product-level flag.
        unitWeight:
          oneOf:
            - { type: "null" }
            - { type: number }
        allowOrderInUnits: { type: boolean }
        minQty:
          oneOf:
            - { type: "null" }
            - { type: number }
        maxQty:
          oneOf:
            - { type: "null" }
            - { type: number }
        stepSize:
          oneOf:
            - { type: "null" }
            - { type: number }
        sizeInBox:
          oneOf:
            - { type: "null" }
            - { type: number }
        recommended: { type: boolean }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    PromoCode:
      type: object
      properties:
        code: { type: string }
        name: { type: string }
        description: { type: string }
        details: { type: string }
        active: { type: boolean }
        type: { type: string, description: "e.g. 'amount' | 'percent' | 'free_delivery'" }
        subtype: { type: string }
        value:
          oneOf:
            - { type: "null" }
            - { type: number }
        totalLimit:
          oneOf:
            - { type: "null" }
            - { type: number }
        used: { type: number }
        perCustomerLimit:
          oneOf:
            - { type: "null" }
            - { type: number }
        validFrom:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        validUntil:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        lastUsed:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    PriceUnits:
      type: object
      required: [defaultUnits, units]
      description: >-
        Unit keys are an enum, not free strings: the storefront renders a
        fixed set of recognized units, and any other value falls back to a
        generic "units" label. `defaultUnits` must also equal one of the
        `unit` values present in `units`.
      properties:
        defaultUnits:
          type: string
          enum: ["kg", "unit", "package", "box", "basket", "bundle", "bunch", "זר", "0.5kg", "100gram", "ליטר", "מ”ר"]
        units:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: object
            required: [unit, price]
            properties:
              unit:
                type: string
                enum: ["kg", "unit", "package", "box", "basket", "bundle", "bunch", "זר", "0.5kg", "100gram", "ליטר", "מ”ר"]
              price: { type: number, minimum: 0 }
    ProductCreate:
      type: object
      additionalProperties: false
      required: [name, category, supplierId, prices, forSale]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000 }
        sku: { type: string, maxLength: 80 }
        category:
          type: string
          minLength: 1
          maxLength: 120
          description: |
            Must already exist on the store (see GET /categories or POST
            /categories to create). `@`-prefixed names are reserved for
            attribute-derived categories and rejected.
        supplierId:
          type: string
          minLength: 6
          maxLength: 64
          pattern: "^[A-Za-z0-9_-]{6,64}$"
          description: |
            Must reference a non-deleted supplier in this store (see GET
            /suppliers or POST /suppliers to create).
        supplierName:
          type: string
          maxLength: 200
          description: |
            Caller-controlled supplier label. Omitted →
            defaults to `""`. `""` → saved as `""`. Any other string →
            saved verbatim, regardless of the referenced supplier's
            `name`/`displayName`. Never auto-derived from the supplier.
        type: { type: string, enum: [regular, bundle, composite] }
        images:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 2048 }
        prices: { $ref: "#/components/schemas/PriceUnits" }
        discounts:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                quantity:
                  type: array
                  maxItems: 20
                  items:
                    type: object
                    required: [quantity, price]
                    properties:
                      quantity: { type: number, minimum: 1 }
                      price: { type: number, minimum: 0 }
        attributes:
          type: array
          maxItems: 20
          description: |
            Attribute tags as plain strings, e.g. ["organic", "vegan"].
            Replaces the product's whole attribute set on write. Any
            attribute the store hasn't seen before is added to the store's
            master attribute list automatically, so it becomes available as
            an attribute-derived category filter. Values are trimmed and
            de-duplicated.
          items: { type: string, minLength: 1, maxLength: 80 }
        inStock: { type: boolean }
        outOfStock: { type: boolean }
        forSale: { type: boolean }
        vatFree:
          type: boolean
          description: |
            Flag the product as VAT-exempt. Mirrored onto each cart row
            at the time the item is added — historical orders snapshot
            the flag. When `Store.vatFreeSeller` is true, every line is
            VAT-exempt regardless of the per-product flag.
        unitWeight: { type: number, minimum: 0 }
        allowOrderInUnits: { type: boolean }
        minQty: { type: number, minimum: 0 }
        maxQty: { type: number, minimum: 0 }
        stepSize: { type: number, exclusiveMinimum: 0 }
        sizeInBox: { type: number, minimum: 0 }
        recommended: { type: boolean }
    ProductUpdate:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000 }
        sku: { type: string, maxLength: 80 }
        category:
          type: string
          minLength: 1
          maxLength: 120
          description: |
            Must already exist on the store. `@`-prefixed names rejected.
        supplierId:
          type: string
          minLength: 6
          maxLength: 64
          pattern: "^[A-Za-z0-9_-]{6,64}$"
          description: |
            Must reference a non-deleted supplier in this store.
        supplierName:
          type: string
          maxLength: 200
          description: |
            Caller-controlled supplier label. Omitted → field
            is left untouched. `""` → saved as `""`. Any other string →
            saved verbatim, regardless of the referenced supplier's
            `name`/`displayName`. Never auto-derived from the supplier.
        type: { type: string, enum: [regular, bundle, composite] }
        images:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 2048 }
        prices: { $ref: "#/components/schemas/PriceUnits" }
        discounts:
          oneOf:
            - { type: "null" }
            - type: object
              properties:
                quantity:
                  type: array
                  maxItems: 20
                  items:
                    type: object
                    required: [quantity, price]
                    properties:
                      quantity: { type: number, minimum: 1 }
                      price: { type: number, minimum: 0 }
        attributes:
          type: array
          maxItems: 20
          description: |
            Attribute tags as plain strings, e.g. ["organic", "vegan"].
            Replaces the product's whole attribute set on write. Any
            attribute the store hasn't seen before is added to the store's
            master attribute list automatically, so it becomes available as
            an attribute-derived category filter. Values are trimmed and
            de-duplicated.
          items: { type: string, minLength: 1, maxLength: 80 }
        inStock: { type: boolean }
        outOfStock: { type: boolean }
        forSale: { type: boolean }
        vatFree:
          type: boolean
          description: |
            Flag the product as VAT-exempt. Updates the source-of-truth
            on the product itself; historical orders are NOT retroactively
            re-taxed (they snapshot the flag at the time the item was
            added to the cart). When `Store.vatFreeSeller` is true,
            every line is VAT-exempt regardless of the per-product flag.
        unitWeight: { type: number, minimum: 0 }
        allowOrderInUnits: { type: boolean }
        minQty: { type: number, minimum: 0 }
        maxQty: { type: number, minimum: 0 }
        stepSize: { type: number, exclusiveMinimum: 0 }
        sizeInBox: { type: number, minimum: 0 }
        recommended: { type: boolean }
    ProductWriteReceipt:
      type: object
      properties:
        success: { type: boolean }
        productId: { type: string }
        changedFields:
          type: array
          items: { type: string }
        ignoredFields:
          type: array
          items: { type: string }
    Content:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [content] }
        title: { type: string }
        text: { type: string }
        images:
          type: array
          items: { type: string }
        coverImage: { type: string }
        category: { type: string }
        authorId: { type: string }
        authorName: { type: string }
        status: { type: string, enum: [draft, published, hidden] }
        backgroundColor: { type: string }
        displayStyle: { type: string, enum: [overlay, classic] }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    ContentCreate:
      type: object
      additionalProperties: false
      required: [category]
      description: |
        At least one of `title`, `text`, or `images` is required (validated
        in the handler, not the schema). `type` is server-stamped and not
        settable from the body.
      properties:
        title: { type: string, maxLength: 200 }
        text: { type: string, maxLength: 10000 }
        images:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 2048 }
        category: { type: string, minLength: 1, maxLength: 120 }
        authorId: { type: string, maxLength: 80 }
        authorName: { type: string, maxLength: 120 }
        status: { type: string, enum: [draft, published, hidden] }
        backgroundColor: { type: string, pattern: "^#[0-9A-Fa-f]{6}$" }
        displayStyle: { type: string, enum: [overlay, classic] }
    ContentUpdate:
      type: object
      additionalProperties: false
      properties:
        title: { type: string, maxLength: 200 }
        text: { type: string, maxLength: 10000 }
        images:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 2048 }
        category: { type: string, minLength: 1, maxLength: 120 }
        authorId: { type: string, maxLength: 80 }
        authorName: { type: string, maxLength: 120 }
        status: { type: string, enum: [draft, published, hidden] }
        backgroundColor: { type: string, pattern: "^#[0-9A-Fa-f]{6}$" }
        displayStyle: { type: string, enum: [overlay, classic] }
    ContentWriteReceipt:
      type: object
      properties:
        success: { type: boolean }
        contentId: { type: string }
        changedFields:
          type: array
          items: { type: string }
        ignoredFields:
          type: array
          items: { type: string }
    PageBlock:
      type: object
      description: |
        A block represents a content section on a page. `props` is an open
        object — the schema enumerates allowed `type` values; per-type
        prop fields are documented in the human-readable README. Rich-text
        props are server-cleaned (no scripts, no `javascript:` URLs).

        **PR #7 additions** (per-type prop shapes documented in the README):
        - `hero.props.layout`: `'background' | 'two-col-text-start' | 'two-col-text-end'`.
          When `'background'` (legacy default), `imageUrl` and `videoUrl` are
          rendered as the section backdrop. When two-column, the renderer
          places `imageUrlSecondary` (or `imageUrl`) beside the text.
        - `hero.props.videoUrl`: optional URL for an autoplaying muted-loop
          background video; `imageUrl` is used as the poster.
        - `cta.props.buttons[]`: `[{ id?, text, link, button? }]`, capped at
          3. When present and non-empty, supersedes the legacy single-button
          `buttonText`/`buttonLink`/`button` trio.
        - `testimonials.props`: `{ heading?, layout: 'cards'|'carousel'|'quote',
          columns: 1..4, avatarShape: 'circle'|'square', showRating: bool,
          items: [{ id?, quote, author, role?, avatarUrl?, rating?, enabled? }] }`,
          items capped at 24. Carries `background`.

        **PR #8 additions** (card styling + structured badges):
        - `cardStyle` block-level prop on `gallery` / `testimonials` /
          `story-flow` / `accordion`. Shape: `{ shadow?, borderRadius?,
          borderColor?, borderWidth?, background?, padding?, hoverLift? }`.
          `shadow` accepts the keyword presets `'none'|'sm'|'md'|'lg'|'xl'`
          OR a raw CSS box-shadow string. `borderWidth` clamps to 0..12.
          Off-allowlist block types silently drop the prop.
        - Per-item `badge` on gallery `images[]`, testimonials `items[]`,
          story-flow `steps[]`, and accordion `items[]`. Two shapes
          accepted on the way IN — plain string (legacy accordion shape
          from PR #5) OR object `{ text, bgColor?, textColor?, variant?
          (solid|soft|outline), uppercase?, icon?, position?
          (top-start|top-end|bottom-start|bottom-end|inline) }`. Sanitizer
          normalizes string → `{ text }` on save.
      properties:
        id: { type: string }
        type:
          type: string
          enum: [hero, text, image, video, gallery, stories, grid, cta, spacer, accordion, story-flow, testimonials]
        enabled: { type: boolean }
        props:
          type: object
          additionalProperties: true
        fontFamily: { type: string }
        fontWeight: { type: string }
        colSpan:
          type: integer
          minimum: 1
          maximum: 12
    Page:
      type: object
      properties:
        id: { type: string }
        slug:
          type: string
          description: |
            URL slug (lowercase letters, digits, hyphens; ≤15 chars; reserved
            slugs like `admin`, `cart` are rejected).
        title: { type: string }
        enabled: { type: boolean }
        priceListLinkId:
          oneOf:
            - { type: "null" }
            - { type: string }
          description: |
            `null` for public pages; a non-null string scopes the page to a
            specific price-list link.
        fontFamily: { type: string }
        fontWeight: { type: string }
        backgroundColor: { type: string }
        direction:
          type: string
          enum: ['', 'rtl', 'ltr']
          description: |
            Per-page text direction override. `'rtl'` or `'ltr'` forces the
            page root's `dir` attribute regardless of the viewer's UI
            language; `''` (default) inherits from the store language.
        defaults:
          type: object
          additionalProperties: true
          description: |
            `{ media: {...}, button: {...}, fonts: {...} }`. CSS values are
            sanitized server-side.
        seo:
          type: object
          properties:
            title: { type: string }
            description: { type: string }
            ogImage: { type: string }
            type: { type: string }
            noIndex: { type: boolean }
        blocks:
          type: array
          items: { $ref: "#/components/schemas/PageBlock" }
        blockCount: { type: integer }
        createdAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
        updatedAt:
          oneOf:
            - { type: "null" }
            - { type: string, format: date-time }
    PageWrite:
      type: object
      required: [slug, title]
      properties:
        slug:
          type: string
          description: |
            Required. `[a-z0-9]+(-[a-z0-9]+)*`, ≤15 chars. Reserved slugs
            (e.g. `admin`, `cart`, `checkout`) are rejected.
          pattern: "^[a-z0-9]+(-[a-z0-9]+)*$"
          maxLength: 15
        title: { type: string, maxLength: 200, minLength: 1 }
        enabled: { type: boolean }
        priceListLinkId:
          oneOf:
            - { type: "null" }
            - { type: string }
        fontFamily: { type: string }
        fontWeight: { type: string }
        backgroundColor: { type: string }
        direction:
          type: string
          enum: ['', 'rtl', 'ltr']
          description: |
            Per-page text direction override. `'rtl'` or `'ltr'` forces the
            page root's `dir` attribute regardless of the viewer's UI
            language; `''` (default) inherits from the store language.
        defaults:
          type: object
          additionalProperties: true
        seo:
          type: object
          properties:
            title: { type: string, maxLength: 200 }
            description: { type: string, maxLength: 500 }
            ogImage: { type: string }
            type: { type: string, maxLength: 20 }
            noIndex: { type: boolean }
        blocks:
          type: array
          maxItems: 200
          items: { $ref: "#/components/schemas/PageBlock" }
    PageWriteReceipt:
      type: object
      properties:
        success: { type: boolean }
        pageId: { type: string }
        changedFields:
          type: array
          items: { type: string }
        ignoredFields:
          type: array
          items: { type: string }
    PagePatchOp:
      type: object
      required: [op]
      properties:
        op:
          type: string
          enum: [replace, set, addBlock, moveBlock, removeBlock, setEnabled]
        blockId: { type: string, description: "Required for replace / setEnabled / moveBlock / removeBlock" }
        field:
          type: string
          description: |
            For `replace`: the block field to set. Allowed: `props.<key>`
            (one level deep), `enabled`, `colSpan`, `fontFamily`,
            `fontWeight`. `id` and `type` are immutable.
        path:
          type: string
          description: |
            For `set`: dot-separated top-level page field. Allowed roots:
            slug, title, enabled, priceListLinkId, fontFamily, fontWeight,
            backgroundColor, background, direction, defaults, seo. `blocks`
            is rejected — use addBlock / moveBlock / removeBlock.
        value:
          description: New value for `replace` / `set` / `setEnabled`.
        at: { type: integer, minimum: 0, description: "addBlock insert index (omit to append)" }
        to: { type: integer, minimum: 0, description: "moveBlock target index" }
        block:
          type: object
          description: For addBlock — the same shape used in createPage / updatePage block entries.
          additionalProperties: true
          properties:
            id: { type: string }
            type: { type: string, enum: [hero, text, image, video, gallery, stories, grid, cta, spacer, accordion, story-flow, testimonials] }
            enabled: { type: boolean }
            props: { type: object, additionalProperties: true }
            colSpan: { type: integer, minimum: 1, maximum: 12 }
            fontFamily: { type: string }
            fontWeight: { type: string }
    PagePatchRequest:
      type: object
      required: [ops]
      properties:
        ops:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: "#/components/schemas/PagePatchOp" }
        expectedVersion:
          type: integer
          minimum: 0
          description: |
            Optional optimistic-concurrency guard. If supplied and the
            server's current `version` differs, the request returns 409
            without mutating state.
    PagePatchReceipt:
      type: object
      properties:
        success: { type: boolean }
        version: { type: integer, description: "New monotonic version after the patch" }
        appliedOps: { type: integer }
    Media:
      type: object
      description: |
        A media asset record. The `url` field is a permanent download
        URL with an embedded token.
      properties:
        id: { type: string }
        url:
          type: string
          format: uri
          description: |
            Permanent download URL with an embedded per-file access
            token. Stays valid as long as the asset exists.
        path: { type: string, description: "Server-managed storage path (per-store, per-month)." }
        contentType:
          type: string
          enum: [image/png, image/jpeg, image/webp, image/gif]
        sizeBytes: { type: integer, format: int64 }
        createdAt: { type: string, format: date-time, nullable: true }
    MediaUploadDirect:
      type: object
      required: [contentType, dataBase64]
      properties:
        contentType:
          type: string
          enum: [image/png, image/jpeg, image/webp, image/gif]
        dataBase64:
          type: string
          description: Base64 of the raw image bytes. Decoded size must be ≤ 10 MiB.
        filename:
          type: string
          description: Optional. Sanitized to ASCII slug (length-capped to 60 chars). Extension must agree with contentType.
    PriceList:
      type: object
      description: |
        Public projection of a price list. Internal bookkeeping fields
        are intentionally omitted. Products are exposed by mode, mutually
        exclusively: table mode → `includedProducts` (+ `productsCount`)
        with `rulesData: null`; rules mode → sanitized `rulesData` with
        `includedProducts: null` and `productsCount: null`.
      properties:
        id: { type: string, description: "Identifier." }
        linkId:
          type: string
          description: |
            Public-facing slug. Lowercased. Unique within the store
            (excluding soft-deleted lists). Same value referenced by
            `Page.priceListLinkId`.
        name:         { type: string, description: "Internal name." }
        displayName:  { type: string, description: "Customer-facing display name." }
        productsMode:
          type: string
          enum: [table, rules]
          description: |
            How products are selected for this list. `table` = explicit
            included-products map. `rules` = matched against the
            store's catalog using `rulesData`.
        productsCount:
          oneOf:
            - { type: integer, minimum: 0 }
            - { type: "null" }
          description: |
            Count of products in the manual `includedProducts` map.
            `null` when `productsMode === 'rules'`.
        active:               { type: boolean }
        alwaysOpen:           { type: boolean, description: "List remains shoppable even when the store is closed." }
        useOriginalBasePrice: { type: boolean }
        coverImage:           { type: string }
        welcomeMessageOpen:   { type: string }
        welcomeMessageClosed: { type: string }
        rulesData:
          oneOf:
            - type: object
              description: Sanitized passthrough — only known keys.
              properties:
                selectAllRule:        { type: boolean }
                selectCategoriesRule: { type: boolean }
                selectAttributesRule: { type: boolean }
                allRules:        { type: object, additionalProperties: true }
                categoriesRules: { type: object, additionalProperties: true }
                attributesRules: { type: object, additionalProperties: true }
            - { type: "null" }
          description: |
            Present only when `productsMode === 'rules'`. `null` otherwise.
        includedProducts:
          oneOf:
            - type: object
              description: |
                Per-product price-override map keyed `category → productId →
                { prices?, type? }`. A `{}` entry means the product is in the
                list at its base price; `type:"content"` marks a content
                inclusion. Product names/details are NOT included here —
                resolve them via the products endpoint.
              additionalProperties:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    prices:
                      type: object
                      additionalProperties: { type: number }
                    type: { type: string, enum: [content] }
            - { type: "null" }
          description: |
            Present only when `productsMode === 'table'`. `null` otherwise.
        createdAt:     { type: string, format: date-time, nullable: true }
        lastUpdatedAt: { type: string, format: date-time, nullable: true }
    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        error: { type: string }
    RangeReportKeyFigures:
      type: object
      description: |
        Aggregate totals over the requested window. Numeric fields
        default to `0` when no orders matched. `totalCosts` is present
        only when the store's plan exposes cost data.
      properties:
        noOfOrders:         { type: number, description: "Total orders matched (including canceled)." }
        noOfCanceledOrders: { type: number }
        totalOrders:        { type: number, description: "Sum of finalized order totals (`totalToPay`)." }
        totalMissing:       { type: number, description: "Sum of unfulfilled-line value across orders." }
        totalDeliveries:    { type: number, description: "Sum of delivery charges." }
        noOfDeliveries:     { type: number, description: "Count of orders with delivery (vs pickup)." }
        totalOriginal:      { type: number, description: "Sum of pre-discount order totals." }
        totalDiscounts:     { type: number, description: "Sum of discounts applied across orders." }
        totalCosts:
          type: number
          description: |
            Sum of cost-of-goods across delivered units. Cost-plan
            stores only — omitted from the response otherwise.
    RangeReportOrder:
      type: object
      description: |
        Per-order row. Money fields are pre-VAT-aware totals matching
        the store admin's "Reports" page rows. Date fields are ISO
        8601 (UTC) when tracked; `null` when the merchant has not
        stamped the corresponding event yet.
      properties:
        id:                  { type: string, description: "Order id." }
        finalizedAt:         { type: string, format: date-time, nullable: true }
        deliveryDate:
          type: string
          nullable: true
          description: "Customer-chosen delivery slot, formatted DD/MM/YYYY. `null` when no slot was chosen."
        deliveredAt:         { type: string, format: date-time, nullable: true }
        shippedAt:           { type: string, format: date-time, nullable: true }
        paidAt:              { type: string, format: date-time, nullable: true }
        fullName:            { type: string }
        customerId:
          type: string
          description: "Stable identifier for the customer; usable as a join key against the per-product `customers` map."
        originalOrder:       { type: number }
        totalMissing:        { type: number }
        deliverySum:         { type: number }
        totalDiscount:       { type: number }
        totalToPay:          { type: number }
        discountComment:     { type: string }
        canceled:            { type: boolean }
        canceledBy:          { type: string }
        canceledAt:          { type: string, format: date-time, nullable: true }
        address:             { type: string }
        city:                { type: string }
        phoneNumber:         { type: string }
        carrier:
          type: string
          nullable: true
          description: "Resolved carrier display name. `null` when no carrier is assigned."
        scheduledOrderParent: { type: string }
        referrer:            { type: string }
        queryParams:         { type: string }
        deliveryMethod:      { type: string, description: "Localized: 'משלוח' for delivery, 'איסוף' for pickup." }
        deliveryOption:      { type: string, description: "Display label of the chosen delivery/pickup option." }
        paymentMethod:       { type: string }
        customerComment:     { type: string }
        managerComment:      { type: string }
        delivered:           { type: boolean }
        paid:                { type: boolean }
        closed:              { type: boolean }
        firstOrder:          { type: boolean }
        promoCode:           { type: string }
        priceList:           { type: string, description: "linkId of the price list applied to the order, when any." }
    RangeReportPersonRow:
      type: object
      description: |
        Per-customer leaf inside a product rollup. `customerNames` is
        an array (a single customer can have placed orders under
        multiple display names over time).
      properties:
        qty:                  { type: number }
        missingQty:           { type: number }
        ordersSum:            { type: number }
        ordersVat:            { type: number }
        totalCost:            { type: number, description: "Cost-plan only." }
        totalSumWithoutCost:  { type: number, description: "Cost-plan only." }
        totalOrdersWithoutCost: { type: number, description: "Cost-plan only." }
        customerNames:
          type: array
          items: { type: string }
        orders:
          type: object
          additionalProperties:
            type: string
            description: "Order id → order date string (DD/MM/YYYY for delivery_date queries; ISO 8601 for order_date queries)."
    RangeReportProductRow:
      type: object
      description: |
        Per-product rollup. The map key on the parent object is a
        synthetic composite of productId, supplier, name, and units,
        so the same product sold under different units rolls up
        separately.
      properties:
        sku:                    { type: string }
        name:                   { type: string }
        category:               { type: string }
        supplierId:             { type: string }
        supplierName:           { type: string }
        units:                  { type: string }
        totalOriginal:          { type: number }
        totalMissing:           { type: number }
        totalOrders:            { type: number }
        totalSum:               { type: number }
        totalVat:               { type: number }
        totalCost:              { type: number, description: "Cost-plan only." }
        unitCost:               { type: number, description: "Cost-plan only." }
        totalSumWithoutCost:    { type: number, description: "Cost-plan only." }
        totalOrdersWithoutCost: { type: number, description: "Cost-plan only." }
        customers:
          type: object
          description: "Per-customer breakdown keyed by `customerId`."
          additionalProperties: { $ref: "#/components/schemas/RangeReportPersonRow" }
    RangeReport:
      type: object
      properties:
        success: { type: boolean, example: true }
        range:
          type: object
          properties:
            startTime: { type: string, format: date-time }
            endTime:   { type: string, format: date-time }
        fetchBy: { type: string, enum: [order_date, delivery_date] }
        keyFigures: { $ref: "#/components/schemas/RangeReportKeyFigures" }
        orders:
          type: array
          items: { $ref: "#/components/schemas/RangeReportOrder" }
        products:
          type: object
          description: |
            Map of synthetic composite keys → per-product rollup. The
            key shape is opaque; consumers should iterate values rather
            than parse the key.
          additionalProperties: { $ref: "#/components/schemas/RangeReportProductRow" }
    Transaction:
      type: object
      description: |
        One row of the transactions report. `isDuplicate` is set when
        the report's dedup pass finds a matching approval number on
        the same order; `isDuplicateInvoice` is set when the invoice
        link is repeated. Both are derived signals — the underlying
        `status` for live rows is always `completed`.
      properties:
        id:               { type: string }
        status:           { type: string, enum: [completed, duplicate] }
        type:             { type: string }
        action:           { type: string, description: "Always `immediate` or `immediate_and_token` on this surface." }
        method:           { type: string, description: "Payment method (e.g. credit_card)." }
        customerId:       { type: string }
        customerName:     { type: string }
        billingName:      { type: string }
        orderId:          { type: string }
        executedBy:       { type: string, description: "Identifier of the manager that executed the action." }
        comment:          { type: string }
        sum:              { type: number, nullable: true }
        vat:              { type: number, nullable: true }
        approvalNumber:   { type: string }
        numOfPayments:    { type: number, nullable: true }
        issueInvoice:     { type: boolean }
        creditTransaction: { type: boolean }
        ignoreInBalance:  { type: boolean }
        documentType:     { type: string }
        invoiceId:        { type: string }
        invoiceNumber:    { type: string }
        invoiceLink:      { type: string }
        invoiceDescription: { type: string }
        invoiceTransactionDocId: { type: string }
        cancelationTransactionIds:
          type: array
          items: { type: string }
        duplicateDocument: { type: boolean }
        isDuplicate:      { type: boolean }
        isDuplicateInvoice: { type: boolean }
        createdAt:        { type: string, format: date-time, nullable: true }
    TransactionsReport:
      type: object
      properties:
        success: { type: boolean, example: true }
        range:
          type: object
          properties:
            startTime: { type: string, format: date-time }
            endTime:   { type: string, format: date-time }
        count: { type: integer }
        totals:
          type: object
          description: |
            Headline totals over the window, computed with the same rule as
            the store admin's Transactions tab: credit-invoice rows are
            excluded; credit-transaction rows subtract their absolute sum.
          properties:
            sumWithVat: { type: number }
            vat: { type: number }
            sumWithoutVat: { type: number }
        transactions:
          type: array
          items: { $ref: "#/components/schemas/Transaction" }
  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Insufficient scope or wrong store
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Body exceeds 100 KiB
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Rate limit exceeded — back off and respect Retry-After
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServiceUnavailable:
      description: |
        The durable audit / usage subsystem is temporarily unavailable.
        Retry with exponential backoff; the condition is normally transient (seconds-to-minutes).

        **Idempotency note**: a 503 may follow a successful handler-write
        (e.g. `POST /orders` may have created the failed subsystem).
        Re-issuing the same request can therefore produce
        a duplicate on `ADDITIVE_WRITE` endpoints. Use the platform's
        idempotency helpers — natural-unique fields (`email`,
        supplier `name`, page `(url, priceListLinkId)`, category) make
        those `POST`s retry-safe by returning `200 + existing: true` when
        the row already exists. (Products are exempt — SKUs are not unique,
        so `POST /products` always creates a new product.)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
