openapi: 3.0.3
info:
  title: PortalPay API
  version: "1.0.0"
  contact:
    name: PortalPay Support
    url: https://ledger1.ai
    email: support@ledger1.ai
  description: |
    Public API surface for PortalPay under a hardened posture: Azure Front Door Premium (AFD) gateway → Azure API Management (APIM).
    All developer-facing endpoints require an APIM subscription key header `Ocp-Apim-Subscription-Key` and use the APIM custom domain. AFD is optional as a fallback.
    APIM policy permits requests with a valid subscription key and may also accept AFD-originated requests carrying `x-edge-secret` if AFD is configured. Direct backend origins are not supported.
    Wallet identity is resolved at the gateway based on subscription; client-supplied `x-wallet` header and `wallet` parameters are ignored.
    Health endpoint: `GET /portalpay/healthz` returns 200 JSON for probes; does not require a subscription.
    Note: Some endpoints are read-only or environment-gated in production. Admin/sensitive routes (JWT-only) are not part of this spec.
  x-origin-enforcement: apim-first
  x-internal-headers:
    - name: x-edge-secret
      description: Gateway-internal header injected by Azure Front Door. Clients must not send this. Validated by APIM inbound policy to enforce origin.

servers:
  - url: https://api.pay.ledger1.ai/portalpay
    description: Production (APIM custom domain)

# Default security requirement for all operations (override per-operation if needed)
security:
  - apimKey: []

tags:
  - name: Receipts
    description: Receipt creation, retrieval, and status operations
  - name: Orders
    description: Order generation from inventory with tax and processing fee computation
  - name: Inventory
    description: Product catalog CRUD
  - name: Shop
    description: Merchant shop configuration (developer read)
  - name: Site
    description: Public site configuration and branding
  - name: Split
    description: Split indexer and transactions
  - name: Health
    description: Health checks and readiness probes
  - name: Billing
    description: Balance checks and purchases
  - name: Pricing
    description: Pricing configuration
  - name: Tax
    description: Tax catalog and jurisdictions
  - name: Reserve
    description: Reserve wallet analytics and recommendations

paths:
  /api/inventory:
    get:
      summary: List inventory items
      operationId: listInventory
      tags: [Inventory]
      description: >
        List inventory items for the merchant associated with the resolved wallet.
        Requires APIM subscription and `inventory:read` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - inventory:read
      parameters:
        - in: query
          name: q
          schema: { type: string }
          required: false
          description: Search term (name, SKU, description, category, tags)
        - in: query
          name: category
          schema: { type: string }
          required: false
        - in: query
          name: taxable
          schema:
            type: string
            enum: ["true", "false", "any"]
          required: false
        - in: query
          name: stock
          schema:
            type: string
            enum: [in, out, any]
          required: false
        - in: query
          name: priceMin
          schema: { type: number, format: float }
          required: false
        - in: query
          name: priceMax
          schema: { type: number, format: float }
          required: false
        - in: query
          name: tags
          schema: { type: string }
          required: false
          description: Comma-separated tags
        - in: query
          name: tagsMode
          schema:
            type: string
            enum: [any, all]
          required: false
          description: Tag match mode
        - in: query
          name: pack
          schema: { type: string }
          required: false
          description: Industry pack filter
        - in: query
          name: sort
          schema:
            type: string
            enum: [name, sku, priceUsd, stockQty, updatedAt]
            default: updatedAt
          required: false
        - in: query
          name: order
          schema:
            type: string
            enum: [asc, desc]
            default: desc
          required: false
        - in: query
          name: limit
          schema: { type: integer, format: int32 }
          required: false
        - in: query
          name: page
          schema: { type: integer, format: int32 }
          required: false
      responses:
        "200":
          description: Inventory list
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InventoryListResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      summary: Create or update an inventory item
      operationId: upsertInventory
      tags: [Inventory]
      description: >
        Upsert (create/update) an inventory item. Requires APIM subscription and `inventory:write` scope.
        Ownership/admin is enforced for JWT callers; APIM callers are bound to their subscription wallet.
      x-required-scopes:
        - inventory:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InventoryUpsertRequest"
      responses:
        "200":
          description: Upserted item
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InventoryUpsertResponse"
        "400":
          description: Invalid input
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    delete:
      summary: Delete an inventory item
      operationId: deleteInventory
      tags: [Inventory]
      description: >
        Delete an inventory item by ID. Requires APIM subscription and `inventory:write` scope.
      x-required-scopes:
        - inventory:write
      parameters:
        - in: query
          name: id
          schema: { type: string }
          required: true
          description: Inventory item ID
      responses:
        "200":
          description: Delete result
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteInventoryResponse"
        "400":
          description: id_required
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/orders:
    post:
      summary: Generate an order/receipt from inventory
      operationId: createOrder
      tags: [Orders]
      description: >
        Generate an itemized receipt from inventory items with automatic tax computation and processing fee.
        Requires APIM subscription and `orders:create` scope.
        Split contract must be configured for the merchant.
      x-required-scopes:
        - orders:create
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCreateRequest"
            examples:
              default:
                value:
                  items:
                    - { sku: "COFFEE-001", qty: 2 }
                    - { sku: "PASTRY-001", qty: 1 }
                  jurisdictionCode: "US-CA"
      responses:
        "200":
          description: Receipt generated
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderCreateResponse"
        "400":
          description: items_required or inventory_item_not_found
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          description: split_required or forbidden
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  message: { type: string }
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/shop/config:
    get:
      summary: Get merchant shop configuration
      operationId: getShopConfig
      tags: [Shop]
      description: >
        Returns merchant shop configuration (branding, layout, tax defaults).
        Requires APIM subscription and `shop:read` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - shop:read
      responses:
        "200":
          description: Shop configuration
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    $ref: "#/components/schemas/ShopConfig"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/receipts:
    get:
      summary: List receipts
      operationId: listReceipts
      tags:
        - Receipts
      description: >
        List recent receipts for the merchant associated with the resolved wallet.
        Requires APIM subscription and `receipts:read` scope.
        Gracefully degrades to in-memory when Cosmos is unavailable.
      x-required-scopes:
        - receipts:read
      parameters:
        - in: query
          name: limit
          schema:
            type: integer
            format: int32
            minimum: 1
            maximum: 200
            default: 100
          required: false
          description: Number of receipts to return (default 100, max 200)
      responses:
        "200":
          description: Recent receipts
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  receipts:
                    type: array
                    items:
                      $ref: "#/components/schemas/Receipt"
              examples:
                default:
                  value:
                    receipts:
                      - receiptId: R-123456
                        totalUsd: 13.09
                        currency: USD
                        lineItems:
                          - { label: "Espresso", priceUsd: 7.00, qty: 2 }
                          - { label: "Tax", priceUsd: 1.09 }
                          - { label: "Processing Fee", priceUsd: 0.50 }
                        createdAt: 1698765432000
                        brandName: "PortalPay"
                        status: "paid"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      summary: Create a receipt payload for a QR-code payment portal
      operationId: createReceipt
      tags:
        - Receipts
      description: >
        Create an itemized receipt used by the payment portal. The portal URL can be
        QR-encoded and printed on POS receipts. Requires APIM subscription and `receipts:write` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - receipts:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateReceiptRequest"
            examples:
              default:
                value:
                  id: rcpt_12345
                  lineItems:
                    - label: Sample Item
                      priceUsd: 25.0
                    - label: Tax
                      priceUsd: 2.0
                  totalUsd: 27.0
      responses:
        "201":
          description: Receipt created
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateReceiptResponse"
              examples:
                default:
                  value:
                    id: rcpt_12345
                    paymentUrl: https://pay.ledger1.ai/pay/rcpt_12345
                    status: pending
        "400":
          description: Invalid request
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Server error

  /api/receipts/{id}:
    get:
      summary: Get a receipt by ID
      operationId: getReceipt
      tags:
        - Receipts
      description: >
        Requires APIM subscription and `receipts:read` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - receipts:read
      parameters:
        - in: path
          name: id
          schema:
            type: string
          required: true
          description: Receipt ID
      responses:
        "200":
          description: Receipt found
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Receipt"
              examples:
                default:
                  value:
                    receiptId: rcpt_12345
                    totalUsd: 27.0
                    currency: USD
                    lineItems:
                      - label: Sample Item
                        priceUsd: 25.0
                      - label: Tax
                        priceUsd: 2.0
                    createdAt: 1698765432000
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          description: Not found
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/receipts/status:
    get:
      summary: Get receipt/payment status
      operationId: getReceiptStatus
      tags:
        - Receipts
      description: >
        Requires APIM subscription and `receipts:read` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - receipts:read
      parameters:
        - in: query
          name: receiptId
          schema:
            type: string
          required: true
          description: Receipt ID to check
      responses:
        "200":
          description: Status payload
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReceiptStatus"
              examples:
                default:
                  value:
                    id: rcpt_12345
                    status: completed
                    transactionHash: 0xabc123...
                    currency: USDC
                    amount: 27
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          description: Not found
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      summary: Update receipt status (tracking and sensitive events)
      operationId: postReceiptStatus
      tags:
        - Receipts
      description: >
        Updates receipt status timeline. Certain tracking statuses may be allowed without JWT (e.g. link_opened, buyer_logged_in, checkout_initialized).
        Sensitive updates (e.g. checkout_success, refund_*) require JWT and CSRF via the portal UI; APIM developer subscriptions typically use tracking statuses.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReceiptStatusUpdateRequest"
      responses:
        "200":
          description: Status update result
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OkResponse"
        "400":
          description: missing_receipt_id or invalid_wallet or missing_status
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: failed

  /api/site/config:
    get:
      summary: Get site configuration (branding and defaults)
      operationId: getSiteConfig
      tags:
        - Site
      description: >
        Returns site-level branding and default payment settings used by the portal.
        May be public/read-only depending on environment; APIM enforcement can be enabled
        in production. If APIM is enabled, rate limiting headers may be present.
        Requests may still be required to traverse AFD in protected environments; direct APIM origin may be rejected with 403.
      # Override default security: allow unauthenticated reads if environment permits.
      security: []
      responses:
        "200":
          description: Site configuration
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiteConfig"
              examples:
                default:
                  value:
                    theme:
                      primaryColor: "#1f2937"
                      secondaryColor: "#F54029"
                      brandLogoUrl: "/cblogod.png"
                      brandFaviconUrl: "/favicon-32x32.png"
                      brandName: "PortalPay"
                      fontFamily: "Inter, ui-sans-serif, system-ui"
                      receiptBackgroundUrl: "/manifest.webmanifest"
                    defaultPaymentToken: "ETH"
                    processingFeePct: 1.0
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/split/transactions:
    get:
      summary: List split transactions (if configured)
      operationId: listSplitTransactions
      tags:
        - Split
      description: >
        Returns transactions indexed by the split indexer. Availability varies by deployment.
        Requires APIM subscription and `split:read` scope.
        Requests must traverse AFD; direct APIM origin may be rejected with 403 (origin enforcement).
      x-required-scopes:
        - split:read
      parameters:
        - in: query
          name: address
          schema:
            type: string
          required: false
          description: Optional filter by recipient/split address
      responses:
        "200":
          description: Array of transactions
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Transaction"
              examples:
                default:
                  value:
                    - id: txn_abc
                      receiptId: rcpt_12345
                      amount: 27.0
                      currency: "USDC"
                      status: "completed"
                      timestamp: "2025-01-01T12:00:00Z"
                      transactionHash: "0x..."
                      fees: 0.2
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /healthz:
    get:
      summary: Health check
      operationId: healthz
      tags:
        - Health
      description: >
        Returns 200 with a small JSON payload for health probes. Subscription is not required.
        Accessible via AFD and APIM; used by AFD origin-group HTTPS probe.
      security: []
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  # New developer-facing endpoints
  /api/billing/balance:
    get:
      summary: Get user balance (time credits)
      operationId: getBillingBalance
      tags: [Billing]
      description: >
        Returns remaining time credits for the authenticated wallet (resolved at gateway). May include plan info if subscribed.
        Requires APIM subscription. Gracefully degrades to zeros if Cosmos is unavailable.
      responses:
        "200":
          description: Balance response
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalanceResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/billing/purchase:
    post:
      summary: Record a purchase/payment event
      operationId: postBillingPurchase
      tags: [Billing]
      description: >
        Records a purchase tied to a merchant recipient and updates receipt status lifecycle.
        Validates optional on-chain transaction recipient. Triggers split indexing when applicable.
        Requires APIM subscription. Wallet is resolved via gateway/JWT or provided header/body in non-prod.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BillingPurchaseRequest"
      responses:
        "200":
          description: Purchase recorded
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BillingPurchaseResponse"
        "400":
          description: invalid wallet or invalid seconds or merchant_required or tx_mismatch
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Server error

  /api/pricing/config:
    get:
      summary: Get pricing configuration
      operationId: getPricingConfig
      tags: [Pricing]
      description: >
        Returns pricing defaults (rate per 2 minutes, minimum minutes, discount rules).
        Requires APIM subscription. Gracefully degrades to defaults if Cosmos is unavailable.
      responses:
        "200":
          description: Pricing configuration
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PricingConfigResponse"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      summary: Update pricing configuration (owner-only JWT; APIM writes not permitted)
      operationId: upsertPricingConfig
      tags: [Pricing]
      description: >
        Updates pricing configuration. Admin-only via JWT and CSRF; APIM dev subscriptions cannot write.
        Included for completeness; not accessible via public developer subscription.
      # Override default security for documentation purposes; this is not callable via APIM key.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PricingConfigUpsertRequest"
      responses:
        "200":
          description: Updated pricing configuration (or degraded fallback)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PricingConfigUpsertResponse"
        "400":
          description: invalid_rate
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "500":
          description: failed

  /api/split/deploy:
    get:
      summary: Get split configuration for merchant
      operationId: getSplitConfig
      tags: [Split]
      description: >
        Returns the split configuration saved for the merchant (address and recipients).
        Requires APIM subscription and `split:read` scope.
      x-required-scopes:
        - split:read
      responses:
        "200":
          description: Split configuration
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SplitConfigResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      summary: Configure split (admin-only JWT; APIM writes not permitted)
      operationId: upsertSplitConfig
      tags: [Split]
      description: >
        Idempotently persists split address and recipients for a merchant. Admin-only via JWT and CSRF.
        APIM developer subscriptions cannot perform this write.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SplitConfigUpsertRequest"
      responses:
        "200":
          description: Split configuration result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SplitConfigUpsertResponse"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "500":
          description: failed

  /api/tax/catalog:
    get:
      summary: Get tax catalog (illustrative jurisdictions and rates)
      operationId: getTaxCatalog
      tags: [Tax]
      description: >
        Returns curated tax jurisdictions with example rates to bootstrap merchant tax settings.
        For production-grade accuracy, integrate a certified tax provider (TaxJar/Avalara).
      # Allow unauthenticated read depending on environment; may be protected by APIM in production.
      security: []
      parameters:
        - in: query
          name: country
          schema: { type: string }
          required: false
          description: Filter by country code (e.g., US, CA, UK, EU, AU, SG, JP)
      responses:
        "200":
          description: Tax catalog response
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaxCatalogResponse"
        "500":
          description: failed

  /api/receipts/refund:
    post:
      summary: Log a refund for a receipt (merchant/admin via portal)
      operationId: postReceiptRefund
      tags: [Receipts]
      description: >
        Logs a refund entry for a receipt and updates status history. Requires JWT and CSRF via portal UI.
        APIM developers typically do not call this directly; included for completeness of public surface.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReceiptRefundRequest"
      responses:
        "200":
          description: Refund logged
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OkResponse"
        "400":
          description: missing_receipt_id or invalid_wallet or invalid_buyer or invalid_usd
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: failed

  /api/receipts/terminal:
    post:
      summary: Generate a terminal receipt (single item + tax/fees)
      operationId: postReceiptTerminal
      tags: [Receipts]
      description: >
        Creates a receipt from a terminal input amount, applies tax and processing fees, and persists.
        Requires APIM subscription; merchant wallet provided via header `x-wallet` or query param `wallet`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReceiptTerminalCreateRequest"
      responses:
        "200":
          description: Terminal receipt generated
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReceiptTerminalCreateResponse"
        "400":
          description: wallet_required or invalid_amount
        "403":
          description: split_required (Split contract not configured)
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: failed

  /api/reserve/balances:
    get:
      summary: Get reserve balances for merchant or owner wallet
      operationId: getReserveBalances
      tags: [Reserve]
      description: >
        Returns live balances across ETH, USDC, USDT, cbBTC, cbXRP with USD conversions and indexed split metrics.
        Requires APIM subscription. Wallet resolved via header `x-wallet`, query `wallet`, or environment default.
      responses:
        "200":
          description: Reserve balances
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReserveBalancesResponse"
        "400":
          description: wallet_unset_or_invalid
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/reserve/recommend:
    get:
      summary: Recommend settlement token and frequency
      operationId: getReserveRecommendation
      tags: [Reserve]
      description: >
        Recommends which token to settle with and how frequently based on target reserve ratios vs current balances.
        Considers optional upcoming receipt amount.
      parameters:
        - in: query
          name: receiptUsd
          schema:
            type: number
            format: float
          required: false
          description: Optional upcoming payment amount to consider
      responses:
        "200":
          description: Recommendation
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReserveRecommendationResponse"
        "500":
          description: recommendation_failed

  /api/split/withdraw:
    post:
      summary: "Deprecated: server-side split withdraw"
      operationId: postSplitWithdrawDeprecated
      tags: [Split]
      description: |
        Deprecated in favor of client-side `PaymentSplitter.release` via connected wallet.
        This endpoint returns 410 Gone and should not be used.
      responses:
        "204":
          description: Deprecated endpoint; returns no content
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
        "410":
          description: Deprecated endpoint; use connected wallet
          headers:
            x-correlation-id:
              description: Correlation ID for tracing
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: deprecated }
                  reason: { type: string, example: use_connected_wallet }
                  message: { type: string }

components:
  securitySchemes:
    apimKey:
      type: apiKey
      in: header
      name: Ocp-Apim-Subscription-Key
      description: |
        Azure API Management subscription key. Required for developer-facing endpoints.
        Obtain your key via PortalPay Admin → API Subscriptions (or directly in APIM).

  headers:
    X-RateLimit-Limit:
      description: Maximum number of requests allowed in the current window
      schema:
        type: integer
        format: int32
    X-RateLimit-Remaining:
      description: Number of requests remaining in the current window
      schema:
        type: integer
        format: int32
    X-RateLimit-Reset:
      description: Unix ms timestamp when the rate limit window resets
      schema:
        type: integer
        format: int64

  responses:
    UnauthorizedError:
      description: Missing or invalid subscription key
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: unauthorized
              message:
                type: string
                example: Missing or invalid subscription key
    ForbiddenError:
      description: Valid key but insufficient scope or disallowed action; or origin enforcement failed (AFD required)
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: forbidden
              message:
                type: string
                example: Insufficient scope or origin enforcement failed
    TooManyRequests:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: rate_limited
              resetAt:
                type: integer
                format: int64
                description: Unix ms timestamp when requests may resume

  schemas:
    LineItem:
      type: object
      properties:
        label:
          type: string
        priceUsd:
          type: number
          format: float
      required: [label, priceUsd]

    Receipt:
      type: object
      properties:
        receiptId:
          type: string
        totalUsd:
          type: number
          format: float
        currency:
          type: string
          enum: ["USD"]
        lineItems:
          type: array
          items:
            $ref: "#/components/schemas/ReceiptLineItem"
        createdAt:
          type: integer
          format: int64
        brandName:
          type: string
          nullable: true
        status:
          type: string
          nullable: true
        jurisdictionCode:
          type: string
          nullable: true
        taxRate:
          type: number
          format: float
          nullable: true
        taxComponents:
          type: array
          items: { type: string }
          nullable: true
        transactionHash:
          type: string
          nullable: true
        transactionTimestamp:
          type: integer
          format: int64
          nullable: true
      required: [receiptId, totalUsd, currency, lineItems, createdAt]

    CreateReceiptRequest:
      type: object
      properties:
        id:
          type: string
          description: Unique receipt ID
        lineItems:
          type: array
          items:
            $ref: "#/components/schemas/LineItem"
        totalUsd:
          type: number
          format: float
      required: [id, lineItems, totalUsd]

    CreateReceiptResponse:
      type: object
      properties:
        id:
          type: string
        paymentUrl:
          type: string
          format: uri
        status:
          type: string
          enum: [pending, completed, failed]
      required: [id, paymentUrl, status]

    ReceiptStatus:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
          enum: [generated, pending, completed, failed, refunded, tx_mined, recipient_validated, tx_mismatch]
        transactionHash:
          type: string
          nullable: true
        currency:
          type: string
          nullable: true
        amount:
          type: number
          format: float
          nullable: true
      required: [id, status]

    Transaction:
      type: object
      properties:
        id:
          type: string
        receiptId:
          type: string
        amount:
          type: number
          format: float
        currency:
          type: string
        status:
          type: string
          enum: [pending, completed, failed]
        timestamp:
          type: string
          format: date-time
        transactionHash:
          type: string
        fees:
          type: number
          format: float

    SiteTheme:
      type: object
      properties:
        primaryColor:
          type: string
        secondaryColor:
          type: string
        brandLogoUrl:
          type: string
        brandFaviconUrl:
          type: string
        brandName:
          type: string
        fontFamily:
          type: string
        receiptBackgroundUrl:
          type: string

    SiteConfig:
      type: object
      properties:
        theme:
          $ref: "#/components/schemas/SiteTheme"
        processingFeePct:
          type: number
          format: float
          nullable: true
        defaultPaymentToken:
          type: string
          enum: [ETH, USDC, USDT, cbBTC, cbXRP]
        reserveRatios:
          type: object
          additionalProperties:
            type: number
          nullable: true

    # Shop configuration schemas (normalized response)
    ShopTheme:
      type: object
      properties:
        primaryColor:
          type: string
        secondaryColor:
          type: string
        textColor:
          type: string
        accentColor:
          type: string
        brandLogoUrl:
          type: string
        coverPhotoUrl:
          type: string
        fontFamily:
          type: string
        logoShape:
          type: string
          enum: [square, circle]
        heroFontSize:
          type: string
          enum: [microtext, small, medium, large, xlarge]

    LinkItem:
      type: object
      properties:
        label:
          type: string
        url:
          type: string
      required: [label, url]

    ShopConfig:
      type: object
      properties:
        name:
          type: string
        description:
          type: string
        bio:
          type: string
        theme:
          $ref: "#/components/schemas/ShopTheme"
        arrangement:
          type: string
          enum: [grid, featured_first, groups, carousel]
        xpPerDollar:
          type: number
          format: float
        slug:
          type: string
          nullable: true
        links:
          type: array
          items:
            $ref: "#/components/schemas/LinkItem"
        industryPack:
          type: string
          enum: [restaurant, retail, hotel, freelancer]
          nullable: true
        industryPackActivatedAt:
          type: integer
          format: int64
          nullable: true
        setupComplete:
          type: boolean
        createdAt:
          type: integer
          format: int64
        updatedAt:
          type: integer
          format: int64
      required: [name, theme, arrangement, xpPerDollar, setupComplete, createdAt, updatedAt]

    # Inventory schemas
    InventoryItem:
      type: object
      properties:
        id: { type: string }
        wallet: { type: string }
        sku: { type: string }
        name: { type: string }
        priceUsd: { type: number, format: float }
        currency: { type: string }
        stockQty: { type: integer, format: int32, description: "-1=unlimited, 0=out of stock, >0=qty" }
        category: { type: string }
        description: { type: string }
        tags:
          type: array
          items: { type: string }
        images:
          type: array
          items: { type: string }
          description: "Image data URLs or URLs"
        attributes:
          type: object
          additionalProperties: true
        costUsd: { type: number, format: float }
        taxable: { type: boolean }
        jurisdictionCode: { type: string }
        industryPack: { type: string }
        createdAt: { type: integer, format: int64 }
        updatedAt: { type: integer, format: int64 }
      required: [id, wallet, sku, name, priceUsd, stockQty, createdAt, updatedAt]

    InventoryListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/InventoryItem"
        total: { type: integer, format: int32 }
        page: { type: integer, format: int32 }
        pageSize: { type: integer, format: int32 }
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true

    InventoryUpsertRequest:
      type: object
      properties:
        id: { type: string, description: "Item ID for updates (optional)" }
        sku: { type: string }
        name: { type: string }
        priceUsd: { type: number, format: float }
        stockQty: { type: integer, format: int32 }
        currency: { type: string }
        category: { type: string }
        description: { type: string }
        tags:
          type: array
          items: { type: string }
        images:
          type: array
          items: { type: string }
        attributes:
          type: object
          additionalProperties: true
        costUsd: { type: number, format: float }
        taxable: { type: boolean }
        jurisdictionCode: { type: string }
        industryPack: { type: string }
      required: [sku, name, priceUsd, stockQty]

    InventoryUpsertResponse:
      type: object
      properties:
        ok: { type: boolean }
        item:
          $ref: "#/components/schemas/InventoryItem"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, item]

    DeleteInventoryResponse:
      type: object
      properties:
        ok: { type: boolean }
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok]

    # Orders schemas
    OrderItem:
      type: object
      properties:
        id: { type: string }
        sku: { type: string }
        qty: { type: integer, format: int32, minimum: 1 }
      required: [qty]

    OrderCreateRequest:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/OrderItem"
        jurisdictionCode:
          type: string
          description: "Tax jurisdiction code (e.g., US-CA)"
        taxRate:
          type: number
          format: float
          description: "Override tax rate (0..1)"
        taxComponents:
          type: array
          items: { type: string }
      required: [items]

    ReceiptLineItem:
      type: object
      properties:
        label: { type: string }
        priceUsd: { type: number, format: float }
        qty: { type: integer, format: int32 }
        thumb: { type: string }
        itemId: { type: string }
        sku: { type: string }
      required: [label, priceUsd]

    GeneratedReceipt:
      type: object
      properties:
        receiptId: { type: string }
        totalUsd: { type: number, format: float }
        currency: { type: string, enum: ["USD"] }
        lineItems:
          type: array
          items:
            $ref: "#/components/schemas/ReceiptLineItem"
        createdAt: { type: integer, format: int64 }
        brandName: { type: string }
        status: { type: string, enum: [generated, pending, completed, failed, refunded] }
        jurisdictionCode: { type: string }
        taxRate: { type: number, format: float }
        taxComponents:
          type: array
          items: { type: string }
      required: [receiptId, totalUsd, currency, lineItems, createdAt, status]

    OrderCreateResponse:
      type: object
      properties:
        ok: { type: boolean }
        receipt:
          $ref: "#/components/schemas/GeneratedReceipt"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, receipt]

    # Billing schemas
    BalanceResponse:
      type: object
      properties:
        balanceSeconds:
          type: integer
          format: int64
        purchasedSeconds:
          type: integer
          format: int64
        usedSeconds:
          type: integer
          format: int64
        unlimited:
          type: boolean
          nullable: true
        plan:
          type: string
          enum: [basic, unlimited]
          nullable: true
        planExpiry:
          type: integer
          format: int64
          nullable: true
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [balanceSeconds, purchasedSeconds, usedSeconds]

    BillingPurchaseRequest:
      type: object
      properties:
        wallet:
          type: string
          description: Buyer wallet (resolved at gateway if omitted)
        seconds:
          type: integer
          format: int64
          description: Time credits purchased (seconds)
        usd:
          type: number
          format: float
          nullable: true
        eth:
          type: number
          format: float
          nullable: true
        txHash:
          type: string
          nullable: true
        idempotencyKey:
          type: string
          nullable: true
        recipient:
          type: string
          description: Merchant recipient wallet
          nullable: true
        receiptId:
          type: string
          nullable: true
      required: [seconds]

    BillingEvent:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [purchase] }
        wallet: { type: string }
        seconds: { type: integer, format: int64 }
        usd: { type: number, format: float, nullable: true }
        eth: { type: number, format: float, nullable: true }
        txHash: { type: string, nullable: true }
        recipient: { type: string, nullable: true }
        receiptId: { type: string, nullable: true }
        portalFeePct: { type: number, format: float }
        portalFeeRecipient: { type: string, nullable: true }
        portalFeeUsd: { type: number, format: float, nullable: true }
        ts: { type: integer, format: int64 }
      required: [id, type, wallet, seconds, portalFeePct, ts]

    BillingPurchaseResponse:
      type: object
      properties:
        ok: { type: boolean }
        event:
          $ref: "#/components/schemas/BillingEvent"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, event]

    # Pricing schemas
    DiscountRule:
      type: object
      properties:
        minMinutes:
          type: integer
          format: int32
        discountPct:
          type: number
          format: float
      required: [minMinutes, discountPct]

    PricingConfig:
      type: object
      properties:
        ethPer2Min:
          type: number
          format: float
        minMinutes:
          type: integer
          format: int32
        discountRules:
          type: array
          items:
            $ref: "#/components/schemas/DiscountRule"
      required: [ethPer2Min, minMinutes, discountRules]

    PricingConfigResponse:
      type: object
      properties:
        config:
          $ref: "#/components/schemas/PricingConfig"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [config]

    PricingConfigUpsertRequest:
      type: object
      properties:
        ethPer2Min:
          type: number
          format: float
        minMinutes:
          type: integer
          format: int32
        discountRules:
          type: array
          items:
            $ref: "#/components/schemas/DiscountRule"
      required: [ethPer2Min, minMinutes]

    PricingConfigUpsertResponse:
      type: object
      properties:
        ok:
          type: boolean
        config:
          $ref: "#/components/schemas/PricingConfig"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, config]

    # Split config schemas
    SplitRecipient:
      type: object
      properties:
        address:
          type: string
        sharesBps:
          type: integer
          format: int32
          description: Basis points of split shares (e.g., 9950 = 99.5%)
      required: [address, sharesBps]

    SplitConfig:
      type: object
      properties:
        address:
          type: string
          nullable: true
        recipients:
          type: array
          items:
            $ref: "#/components/schemas/SplitRecipient"

    SplitConfigResponse:
      type: object
      properties:
        split:
          $ref: "#/components/schemas/SplitConfig"
      required: [split]

    SplitConfigUpsertRequest:
      type: object
      properties:
        splitAddress:
          type: string
          description: Optional pre-deployed split contract address
        recipients:
          type: array
          items:
            $ref: "#/components/schemas/SplitRecipient"

    SplitConfigUpsertResponse:
      type: object
      properties:
        ok:
          type: boolean
        split:
          $ref: "#/components/schemas/SplitConfig"
        idempotent:
          type: boolean
          nullable: true
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, split]

    # Tax catalog schemas
    TaxProvider:
      type: object
      properties:
        name:
          type: string
          nullable: true
        apiKeySet:
          type: boolean
          nullable: true

    TaxJurisdiction:
      type: object
      properties:
        code:
          type: string
        name:
          type: string
        rate:
          type: number
          format: float
        country:
          type: string
          nullable: true
        type:
          type: string
          nullable: true
      required: [code, name, rate]

    TaxCatalogResponse:
      type: object
      properties:
        provider:
          $ref: "#/components/schemas/TaxProvider"
        updatedAt:
          type: integer
          format: int64
        jurisdictions:
          type: array
          items:
            $ref: "#/components/schemas/TaxJurisdiction"
        disclaimer:
          type: string
      required: [provider, updatedAt, jurisdictions]

    # Receipts update/refund/terminal schemas
    ReceiptStatusUpdateRequest:
      type: object
      properties:
        receiptId:
          type: string
        wallet:
          type: string
          description: Merchant wallet partition key (0x...)
        status:
          type: string
          description: Status to update (tracking or sensitive)
      required: [receiptId, wallet, status]

    OkResponse:
      type: object
      properties:
        ok:
          type: boolean
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok]

    RefundItem:
      type: object
      properties:
        label:
          type: string
        priceUsd:
          type: number
          format: float
        qty:
          type: integer
          format: int32
          nullable: true
      required: [label, priceUsd]

    ReceiptRefundRequest:
      type: object
      properties:
        receiptId:
          type: string
        wallet:
          type: string
          description: Merchant wallet (0x...)
        buyer:
          type: string
          description: Buyer wallet (0x...)
        usd:
          type: number
          format: float
        items:
          type: array
          items:
            $ref: "#/components/schemas/RefundItem"
        txHash:
          type: string
          nullable: true
      required: [receiptId, wallet, buyer, usd]

    ReceiptTerminalCreateRequest:
      type: object
      properties:
        amountUsd:
          type: number
          format: float
        label:
          type: string
          nullable: true
        currency:
          type: string
          nullable: true
        jurisdictionCode:
          type: string
          nullable: true
        taxRate:
          type: number
          format: float
          nullable: true
        taxComponents:
          type: array
          items: { type: string }
          nullable: true
      required: [amountUsd]

    ReceiptTerminalCreateResponse:
      type: object
      properties:
        ok:
          type: boolean
        receipt:
          $ref: "#/components/schemas/GeneratedReceipt"
        degraded:
          type: boolean
          nullable: true
        reason:
          type: string
          nullable: true
      required: [ok, receipt]

    # Reserve schemas
    ReserveBalanceEntry:
      type: object
      properties:
        units:
          type: number
          format: float
        usd:
          type: number
          format: float
        decimals:
          type: integer
          format: int32
        address:
          type: string
          nullable: true

    ReserveIndexedMetrics:
      type: object
      properties:
        totalVolumeUsd: { type: number, format: float }
        merchantEarnedUsd: { type: number, format: float }
        platformFeeUsd: { type: number, format: float }
        customers: { type: integer, format: int32 }
        totalCustomerXp: { type: integer, format: int32 }
        transactionCount: { type: integer, format: int32 }

    ReserveBalancesResponse:
      type: object
      properties:
        merchantWallet:
          type: string
        sourceWallet:
          type: string
        splitAddressUsed:
          type: string
          nullable: true
        balances:
          type: object
          properties:
            ETH:
              $ref: "#/components/schemas/ReserveBalanceEntry"
            USDC:
              $ref: "#/components/schemas/ReserveBalanceEntry"
            USDT:
              $ref: "#/components/schemas/ReserveBalanceEntry"
            cbBTC:
              $ref: "#/components/schemas/ReserveBalanceEntry"
            cbXRP:
              $ref: "#/components/schemas/ReserveBalanceEntry"
        rates:
          type: object
          properties:
            ETH_USD: { type: number, format: float }
            BTC_USD: { type: number, format: float }
            XRP_USD: { type: number, format: float }
        totalUsd:
          type: number
          format: float
        indexedMetrics:
          $ref: "#/components/schemas/ReserveIndexedMetrics"

    ReserveRecommendationResponse:
      type: object
      properties:
        recommendedToken:
          type: string
          enum: [USDC, USDT, cbBTC, cbXRP, ETH]
        frequency:
          type: integer
          format: int32
          description: Settlement cadence (every N transactions)
        deficitFraction:
          type: number
          format: float
        maxDeficit:
          type: number
          format: float
        totalUsd:
          type: number
          format: float
        futureTotal:
          type: number
          format: float
        targets:
          type: object
          additionalProperties:
            type: number
        currentUsd:
          type: object
          additionalProperties:
            type: number
        deficits:
          type: object
          additionalProperties:
            type: number
