{
  "openapi": "3.1.0",
  "info": {
    "title": "Partner POS Receiver API",
    "version": "0.1.0-draft",
    "description": "Proposed contract for the Noovi-specific receiver the POS partner hosts. Noovi calls the receiver directly over HTTPS for menus and orders. Hosts, IDs and payloads are fictional examples. Onboard a separate Noovi outbound client per connection and environment. Use separate identities for calls from the POS to Noovi."
  },
  "servers": [
    {
      "url": "https://pos.example.test/v1",
      "description": "Example shared partner endpoint for multiple Noovi companies"
    },
    {
      "url": "https://{company}.pos.example.test/v1",
      "description": "Example dedicated partner endpoint for one Noovi company",
      "variables": {
        "company": { "default": "demo" }
      }
    }
  ],
  "security": [{ "nooviOAuth": [], "mutualTLS": [] }],
  "paths": {
    "/menu": {
      "parameters": [{ "$ref": "#/components/parameters/NooviCompany" }],
      "get": {
        "operationId": "getMenu",
        "summary": "Get the mapped location's menu",
        "description": "Return the menu for the authorized company and its mapped location. Keep item IDs stable across price and availability updates. Prices are integers in the specified currency's minor unit.",
        "security": [{ "nooviOAuth": ["menu:read"], "mutualTLS": [] }],
        "responses": {
          "200": {
            "description": "Menu for the mapped location",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Menu" },
                "example": {
                  "locationId": "loc_demo",
                  "currency": "USD",
                  "items": [
                    { "id": "menu-01", "name": "House Salad", "priceMinor": 1200, "available": true }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/orders": {
      "parameters": [{ "$ref": "#/components/parameters/NooviCompany" }],
      "post": {
        "operationId": "createOrder",
        "summary": "Receive a Noovi order",
        "description": "Accept the order and persist the receipt before acknowledging it. Deduplicate by company, location and Noovi order ID. The same idempotency key and payload return the existing receipt even if the initial response was lost. A changed payload for the same key or a conflicting order reference returns 409. This payload supports item lines and tax. Orders with modifiers, discounts, tips, or extra fees require an agreed extension before sending.",
        "security": [{ "nooviOAuth": ["orders:write"], "mutualTLS": [] }],
        "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Order" },
              "example": {
                "orderId": "ORD-DEMO-001",
                "locationId": "loc_demo",
                "fulfillment": { "type": "pickup" },
                "paymentStatus": "paid",
                "items": [{ "itemId": "menu-01", "quantity": 2, "unitPriceMinor": 1200 }],
                "currency": "USD",
                "amounts": { "subtotalMinor": 2400, "taxMinor": 192, "totalMinor": 2592 }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The POS created the order and persisted its receipt",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OrderReceipt" },
                "example": {
                  "orderId": "ORD-DEMO-001",
                  "posOrderId": "pos-987",
                  "locationId": "loc_demo"
                }
              }
            }
          },
          "200": { "$ref": "#/components/responses/ExistingReceipt" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": {
            "description": "The idempotency key was reused with a different payload, or the order reference conflicts with a prior submission",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "422": {
            "description": "The POS rejected the order, such as an unavailable item, invalid amount or unsupported fulfillment mode",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/orders/by-reference/{orderId}": {
      "parameters": [
        { "$ref": "#/components/parameters/NooviCompany" },
        {
          "name": "orderId",
          "in": "path",
          "required": true,
          "description": "The Noovi order ID used to create the order",
          "schema": { "type": "string", "minLength": 1 },
          "example": "ORD-DEMO-001"
        }
      ],
      "get": {
        "operationId": "getOrderByReference",
        "summary": "Look up an order receipt by Noovi reference",
        "description": "Search within the authorized company and mapped location. Noovi uses this endpoint to reconcile an interrupted submission before retrying with the same key and payload.",
        "security": [{ "nooviOAuth": ["orders:read"], "mutualTLS": [] }],
        "responses": {
          "200": { "$ref": "#/components/responses/ExistingReceipt" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": {
            "description": "No receipt exists for the reference in this company and location",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "nooviOAuth": {
        "type": "oauth2",
        "description": "The Noovi authorization server authenticates the registered outbound client with mTLS and issues a short-lived, certificate-bound JWT access token. Send it as Authorization: Bearer over mTLS with the same certificate. The POS receiver verifies the access-token type, exact issuer and configured receiver audience, signature with trusted Noovi signing keys, expiry, and operation scope. Onboard the exact issuer, audience, and Noovi signing-key discovery URL, and refresh keys on rotation. Verify client_id and company_id against the onboarded connection and require company_id to match X-Noovi-Company-Id before resolving the mapped POS location. The illustrated audience is urn:noovi:partner-pos:demo. See [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) and [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html).",
        "flows": {
          "clientCredentials": {
            "tokenUrl": "https://auth.noovi.example.test/oauth/token",
            "scopes": {
              "menu:read": "Read the mapped POS menu",
              "orders:write": "Submit orders to the mapped POS location",
              "orders:read": "Look up order receipts for the mapped POS location"
            }
          }
        }
      },
      "mutualTLS": {
        "type": "mutualTLS",
        "description": "Require the onboarded Noovi client certificate on every request. The POS receiver compares the SHA-256 thumbprint of the presented certificate to cnf.x5t#S256 in the validated access token. See [RFC 8705](https://www.rfc-editor.org/rfc/rfc8705.html)."
      }
    },
    "parameters": {
      "NooviCompany": {
        "name": "X-Noovi-Company-Id",
        "in": "header",
        "required": true,
        "description": "Noovi company ID, required on shared and dedicated endpoints. Require an exact match to the validated token's company_id and the onboarded client-to-company mapping before routing to the POS location. Use the same mapping to scope all reads and writes.",
        "schema": { "type": "string", "minLength": 1 },
        "example": "cmp_demo"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "description": "Noovi's stable key for this submission. Scope it to the authorized company and location. Keep the same key and request body when retrying.",
        "schema": { "type": "string", "minLength": 1 },
        "example": "cmp_demo:ORD-DEMO-001"
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "If provided, time to wait before retrying, expressed as delay in seconds or an HTTP date",
        "schema": { "type": "string" },
        "example": "30"
      }
    },
    "responses": {
      "ExistingReceipt": {
        "description": "Existing POS order receipt",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/OrderReceipt" },
            "example": {
              "orderId": "ORD-DEMO-001",
              "posOrderId": "pos-987",
              "locationId": "loc_demo"
            }
          }
        }
      },
      "BadRequest": {
        "description": "Required header or valid request data is missing",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Access token is missing, invalid, expired or bound to a different client certificate. Invalid client certificates may be rejected during the TLS handshake.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "The validated access token lacks the operation scope or the client, company or location fails the onboarded mapping",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Partner rate limit reached",
        "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unavailable": {
        "description": "Partner endpoint temporarily unavailable",
        "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Currency": {
        "type": "string",
        "pattern": "^[A-Z]{3}$",
        "description": "Three-letter currency code, such as USD"
      },
      "MoneyMinor": { "type": "integer", "minimum": 0 },
      "Menu": {
        "type": "object",
        "required": ["locationId", "currency", "items"],
        "properties": {
          "locationId": { "type": "string", "minLength": 1 },
          "currency": { "$ref": "#/components/schemas/Currency" },
          "items": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/MenuItem" }
          }
        }
      },
      "MenuItem": {
        "type": "object",
        "required": ["id", "name", "priceMinor", "available"],
        "properties": {
          "id": { "type": "string", "minLength": 1 },
          "name": { "type": "string", "minLength": 1 },
          "priceMinor": { "$ref": "#/components/schemas/MoneyMinor" },
          "available": { "type": "boolean" }
        }
      },
      "Order": {
        "type": "object",
        "additionalProperties": false,
        "required": ["orderId", "locationId", "fulfillment", "items", "currency", "amounts", "paymentStatus"],
        "properties": {
          "orderId": { "type": "string", "minLength": 1, "description": "Stable Noovi order ID" },
          "locationId": { "type": "string", "minLength": 1, "description": "POS location mapped to the Noovi company" },
          "fulfillment": { "$ref": "#/components/schemas/Fulfillment" },
          "items": {
            "type": "array",
            "minItems": 1,
            "items": { "$ref": "#/components/schemas/OrderItem" }
          },
          "currency": { "$ref": "#/components/schemas/Currency" },
          "amounts": { "$ref": "#/components/schemas/OrderAmounts" },
          "paymentStatus": {
            "type": "string",
            "enum": ["paid", "unpaid"],
            "description": "Paid means Noovi already collected payment. Record it without charging again. Unpaid means the POS is responsible for collection as agreed during onboarding."
          }
        }
      },
      "Fulfillment": {
        "type": "object",
        "additionalProperties": false,
        "required": ["type"],
        "properties": {
          "type": { "type": "string", "enum": ["pickup", "delivery", "dine_in"] },
          "tableId": { "type": "string", "minLength": 1, "description": "Mapped POS table ID for dine-in" },
          "reservationId": { "type": "string", "minLength": 1, "description": "Noovi reservation reference when the dine-in order has an associated booking" },
          "address": { "$ref": "#/components/schemas/DeliveryAddress" }
        },
        "allOf": [
          {
            "if": { "properties": { "type": { "const": "dine_in" } } },
            "then": { "required": ["tableId"] },
            "else": { "not": { "anyOf": [{ "required": ["tableId"] }, { "required": ["reservationId"] }] } }
          },
          {
            "if": { "properties": { "type": { "const": "delivery" } } },
            "then": { "required": ["address"] },
            "else": { "not": { "required": ["address"] } }
          }
        ]
      },
      "DeliveryAddress": {
        "type": "object",
        "additionalProperties": false,
        "required": ["line1", "city", "postalCode", "countryCode"],
        "properties": {
          "line1": { "type": "string", "minLength": 1 },
          "line2": { "type": "string", "minLength": 1 },
          "city": { "type": "string", "minLength": 1 },
          "region": { "type": "string", "minLength": 1 },
          "postalCode": { "type": "string", "minLength": 1 },
          "countryCode": { "type": "string", "pattern": "^[A-Z]{2}$" }
        }
      },
      "OrderItem": {
        "type": "object",
        "additionalProperties": false,
        "required": ["itemId", "quantity", "unitPriceMinor"],
        "properties": {
          "itemId": { "type": "string", "minLength": 1, "description": "Stable POS menu item ID" },
          "quantity": { "type": "integer", "minimum": 1 },
          "unitPriceMinor": { "$ref": "#/components/schemas/MoneyMinor" }
        }
      },
      "OrderAmounts": {
        "type": "object",
        "additionalProperties": false,
        "required": ["subtotalMinor", "taxMinor", "totalMinor"],
        "description": "All amounts use the order currency. For the initial payload, subtotal is the sum of quantity times unit price and total is subtotal plus tax.",
        "properties": {
          "subtotalMinor": { "$ref": "#/components/schemas/MoneyMinor" },
          "taxMinor": { "$ref": "#/components/schemas/MoneyMinor" },
          "totalMinor": { "$ref": "#/components/schemas/MoneyMinor" }
        }
      },
      "OrderReceipt": {
        "type": "object",
        "required": ["orderId", "posOrderId", "locationId"],
        "properties": {
          "orderId": { "type": "string", "minLength": 1 },
          "posOrderId": { "type": "string", "minLength": 1, "description": "Stable POS order ID" },
          "locationId": { "type": "string", "minLength": 1 }
        }
      },
      "Error": {
        "type": "object",
        "required": ["code", "message"],
        "properties": {
          "code": { "type": "string", "minLength": 1 },
          "message": { "type": "string", "minLength": 1 }
        }
      }
    }
  }
}
