openapi: 3.1.0
info:
  title: Faux POS API
  version: 1.0.0
  description: >
    Restaurant point of sale API for ACME Kitchen & Bar. Every request names a visitor with
    X-Visitor-Id, and each visitor has a private database that starts from the ACME seed.
servers:
  - url: https://fauxpos.bradenevans.dev
  - url: http://127.0.0.1:8788
security:
  - visitor: []
tags:
  - name: Service
  - name: Guests
  - name: Payments
  - name: Kitchen
  - name: Webhooks
  - name: Demo
paths:
  /api/health:
    get:
      tags: [Demo]
      summary: Check that the API is up
      security: []
      responses:
        '200':
          description: The API is up.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, const: ok }
  /api/context:
    get:
      tags: [Service]
      summary: Read the tenant, location, and signed-in employee
      responses:
        '200': { $ref: '#/components/responses/Object' }
  /api/employees:
    get:
      tags: [Service]
      summary: List the crew at a location
      parameters:
        - $ref: '#/components/parameters/LocationId'
      responses:
        '200': { $ref: '#/components/responses/List' }
        '400': { $ref: '#/components/responses/Error' }
  /api/employees/sign-in:
    post:
      tags: [Service]
      summary: Sign a crew member in with a PIN
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employee_id, pin]
              properties:
                employee_id: { type: string }
                pin: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '401': { $ref: '#/components/responses/Error' }
  /api/menu:
    get:
      tags: [Service]
      summary: Read the menu by category
      responses:
        '200': { $ref: '#/components/responses/List' }
  /api/menu/items/{menuItemId}:
    parameters:
      - { name: menuItemId, in: path, required: true, schema: { type: string } }
    patch:
      tags: [Service]
      summary: Add or remove an item from Favorites
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [favorite]
              properties:
                favorite: { type: boolean }
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '400': { $ref: '#/components/responses/Error' }
  /api/checks:
    get:
      tags: [Service]
      summary: List the active checks at a location
      parameters:
        - $ref: '#/components/parameters/LocationId'
      responses:
        '200': { $ref: '#/components/responses/List' }
        '400': { $ref: '#/components/responses/Error' }
    post:
      tags: [Service]
      summary: Open a check on a table
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [location_id, employee_id, table_label]
              properties:
                location_id: { type: string, examples: [loc_downtown] }
                employee_id: { type: string, examples: [emp_jasmine_brooks] }
                table_label: { type: string, examples: ['9'] }
                order_type: { type: string, enum: [dine_in, takeout], default: dine_in }
      responses:
        '201': { $ref: '#/components/responses/Check' }
        '400': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    get:
      tags: [Service]
      summary: Read a check with its lines, payments, and allergy conflicts
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '404': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Service]
      summary: Release an empty check
      responses:
        '204': { description: The check was released. }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/items:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    post:
      tags: [Service]
      summary: Add an item
      description: >
        A 409 with code allergy_conflict names the guest's restriction, the triggering ingredients,
        and up to 3 replacements. Send allergy_override to add the item anyway. The override is recorded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [menu_item_id]
              properties:
                menu_item_id: { type: string, examples: [item_bangkok_bowl] }
                quantity: { type: integer, minimum: 1, maximum: 99, default: 1 }
                allergy_override: { $ref: '#/components/schemas/AllergyOverride' }
      responses:
        '201': { $ref: '#/components/responses/Check' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/items/{itemId}:
    parameters:
      - $ref: '#/components/parameters/CheckId'
      - $ref: '#/components/parameters/ItemId'
    patch:
      tags: [Service]
      summary: Change a line's quantity or note
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                quantity: { type: integer, minimum: 1, maximum: 99 }
                note: { type: [string, 'null'], maxLength: 120 }
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '400': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Service]
      summary: Remove an unfired line
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/items/{itemId}/void:
    parameters:
      - $ref: '#/components/parameters/CheckId'
      - $ref: '#/components/parameters/ItemId'
    post:
      tags: [Service]
      summary: Void a fired line
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employee_id, reason]
              properties:
                employee_id: { type: string }
                reason: { type: string }
                manager_pin: { type: string }
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/items/{itemId}/allergy-override:
    parameters:
      - $ref: '#/components/parameters/CheckId'
      - $ref: '#/components/parameters/ItemId'
    post:
      tags: [Service]
      summary: Record an override for a flagged line
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AllergyOverride' }
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '403': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/send:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    post:
      tags: [Kitchen]
      summary: Fire unfired lines to the kitchen
      description: A repeated request creates no second ticket. An unresolved allergy conflict returns 409.
      responses:
        '200':
          description: The check and its new ticket, or a null ticket if nothing was left to fire.
          content:
            application/json:
              schema:
                type: object
                required: [check, ticket]
                properties:
                  check: { $ref: '#/components/schemas/Check' }
                  ticket: { type: [object, 'null'] }
        '409': { $ref: '#/components/responses/Error' }
  /api/kitchen/tickets:
    get:
      tags: [Kitchen]
      summary: Read the kitchen board
      parameters:
        - $ref: '#/components/parameters/LocationId'
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '400': { $ref: '#/components/responses/Error' }
  /api/kitchen/tickets/{ticketId}:
    parameters:
      - { name: ticketId, in: path, required: true, schema: { type: string } }
    patch:
      tags: [Kitchen]
      summary: Move a ticket along the line
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [in_progress, ready, bumped] }
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '409': { $ref: '#/components/responses/Error' }
  /api/guests:
    get:
      tags: [Guests]
      summary: Search guests by name or phone
      parameters:
        - { name: q, in: query, schema: { type: string }, example: sarah miller }
      responses:
        '200': { $ref: '#/components/responses/List' }
  /api/guests/regulars:
    get:
      tags: [Guests]
      summary: List the regulars shown before a search
      responses:
        '200': { $ref: '#/components/responses/List' }
  /api/guests/{guestId}:
    parameters:
      - $ref: '#/components/parameters/GuestId'
    get:
      tags: [Guests]
      summary: Read a guest's restrictions, visits, loyalty, and rewards
      description: During a loyalty outage, loyalty_status is unavailable and everything else still returns.
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '404': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/guest:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    post:
      tags: [Guests]
      summary: Add a guest to a check
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [guest_id]
              properties:
                guest_id: { type: string, examples: [guest_sarah_miller] }
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '404': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Guests]
      summary: Remove the guest from a check
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/reward:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    post:
      tags: [Guests]
      summary: Redeem a reward on a check
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reward_id]
              properties:
                reward_id: { type: string, examples: [reward_five_off] }
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '409': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
    delete:
      tags: [Guests]
      summary: Remove the reward and return its points
      responses:
        '200': { $ref: '#/components/responses/Check' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/payments:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    post:
      tags: [Payments]
      summary: Take a payment
      description: A repeated idempotency_key returns the first outcome with status 200 and never charges twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idempotency_key, method, amount_cents]
              properties:
                idempotency_key: { type: string }
                method: { type: string, enum: [card, cash] }
                amount_cents: { type: integer, minimum: 0 }
                tip_cents: { type: integer, minimum: 0, default: 0 }
                tendered_cents: { type: integer, minimum: 0, description: Required for cash. }
      responses:
        '200': { $ref: '#/components/responses/Payment' }
        '201': { $ref: '#/components/responses/Payment' }
        '402': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/checks/{checkId}/receipt:
    parameters:
      - $ref: '#/components/parameters/CheckId'
    get:
      tags: [Payments]
      summary: Read a receipt
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '404': { $ref: '#/components/responses/Error' }
  /api/closed-checks:
    get:
      tags: [Payments]
      summary: List recently closed checks at a location
      parameters:
        - $ref: '#/components/parameters/LocationId'
      responses:
        '200': { $ref: '#/components/responses/List' }
        '400': { $ref: '#/components/responses/Error' }
  /api/payments/{paymentId}/void:
    parameters:
      - $ref: '#/components/parameters/PaymentId'
    post:
      tags: [Payments]
      summary: Void a tender while the check is open
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '409': { $ref: '#/components/responses/Error' }
  /api/payments/{paymentId}/refund:
    parameters:
      - $ref: '#/components/parameters/PaymentId'
    post:
      tags: [Payments]
      summary: Refund a tender on a closed check
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '409': { $ref: '#/components/responses/Error' }
  /api/webhooks/deliveries:
    get:
      tags: [Webhooks]
      summary: Read the webhook delivery log
      description: Reading the log also runs any retry that is due.
      responses:
        '200':
          description: The latest 25 deliveries, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookDelivery' }
  /api/webhooks/deliveries/{deliveryId}/redeliver:
    parameters:
      - { name: deliveryId, in: path, required: true, schema: { type: string } }
    post:
      tags: [Webhooks]
      summary: Try a delivery again now
      description: A delivered event is not sent twice.
      responses:
        '200':
          description: The delivery after this attempt.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDelivery' }
        '404': { $ref: '#/components/responses/Error' }
  /api/demo/state:
    get:
      tags: [Demo]
      summary: Read the failure switches
      responses:
        '200': { $ref: '#/components/responses/Object' }
    patch:
      tags: [Demo]
      summary: Turn failure switches on or off
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                payment_failure: { type: boolean }
                loyalty_failure: { type: boolean }
                webhook_failure: { type: boolean }
      responses:
        '200': { $ref: '#/components/responses/Object' }
        '400': { $ref: '#/components/responses/Error' }
  /api/demo/reset:
    post:
      tags: [Demo]
      summary: Restore this visitor's ACME start state
      responses:
        '200': { $ref: '#/components/responses/Object' }
  /api/demo/snapshot:
    get:
      tags: [Demo]
      summary: Read a copy of this visitor's data
      description: The browser keeps this copy so it can carry on if the API becomes unavailable.
      responses:
        '200': { $ref: '#/components/responses/Object' }
components:
  securitySchemes:
    visitor:
      type: apiKey
      in: header
      name: X-Visitor-Id
      description: 8 to 64 letters, digits, or hyphens. A new value starts a new private database.
  parameters:
    LocationId:
      { name: location_id, in: query, required: true, schema: { type: string }, example: loc_downtown }
    CheckId:
      { name: checkId, in: path, required: true, schema: { type: string } }
    ItemId:
      { name: itemId, in: path, required: true, schema: { type: string } }
    GuestId:
      { name: guestId, in: path, required: true, schema: { type: string }, example: guest_sarah_miller }
    PaymentId:
      { name: paymentId, in: path, required: true, schema: { type: string } }
  responses:
    Object:
      description: A JSON object.
      content:
        application/json:
          schema: { type: object }
    List:
      description: A JSON array.
      content:
        application/json:
          schema: { type: array, items: { type: object } }
    Check:
      description: The check after this change.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Check' }
    Payment:
      description: The payment and the check after it. Status 200 means a repeated key returned the first outcome.
      content:
        application/json:
          schema:
            type: object
            required: [payment, check, replayed]
            properties:
              payment: { $ref: '#/components/schemas/Payment' }
              check: { $ref: '#/components/schemas/Check' }
              replayed: { type: boolean }
    Error:
      description: The request was refused.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, examples: [allergy_conflict, card_declined, rate_limited] }
            message: { type: string }
            conflict:
              type: object
              description: Present when code is allergy_conflict.
    AllergyOverride:
      type: object
      required: [employee_id, reason]
      properties:
        employee_id: { type: string }
        reason: { type: string }
        manager_pin: { type: string, description: Required for a severe allergy. }
    Check:
      type: object
      required: [id, check_number, table_label, status, subtotal_cents, discount_cents, tax_cents, total_cents, balance_due_cents, items, payments, allergy_conflicts]
      properties:
        id: { type: string }
        check_number: { type: integer }
        table_label: { type: string }
        order_type: { type: string, enum: [dine_in, takeout] }
        status: { type: string, enum: [open, sent, paid, void] }
        subtotal_cents: { type: integer }
        discount_cents: { type: integer }
        tax_cents: { type: integer }
        tip_cents: { type: integer }
        total_cents: { type: integer }
        balance_due_cents: { type: integer }
        guest_id: { type: [string, 'null'] }
        guest_name: { type: [string, 'null'] }
        reward_id: { type: [string, 'null'] }
        reward_name: { type: [string, 'null'] }
        items:
          type: array
          items:
            type: object
            required: [id, menu_item_id, item_name, unit_price_cents, quantity, status]
            properties:
              id: { type: string }
              menu_item_id: { type: string }
              item_name: { type: string }
              unit_price_cents: { type: integer }
              quantity: { type: integer }
              status: { type: string, enum: [pending, sent, void] }
              note: { type: [string, 'null'] }
        payments:
          type: array
          items: { $ref: '#/components/schemas/Payment' }
        allergy_conflicts:
          type: array
          items: { type: object }
    Payment:
      type: object
      required: [id, status, method, amount_cents, tip_cents]
      properties:
        id: { type: string }
        status: { type: string, enum: [processing, approved, declined, voided, refunded] }
        method: { type: string, enum: [card, cash] }
        amount_cents: { type: integer }
        tip_cents: { type: integer }
        card_brand: { type: [string, 'null'] }
        card_last4: { type: [string, 'null'] }
        tendered_cents: { type: [integer, 'null'] }
        change_cents: { type: [integer, 'null'] }
        processor_transaction_id: { type: [string, 'null'] }
    WebhookDelivery:
      type: object
      required: [id, event_id, event_type, payment_id, endpoint_url, payload, status, attempt_count, attempts]
      properties:
        id: { type: string }
        event_id: { type: string }
        event_type: { type: string, examples: [payment.approved] }
        payment_id: { type: string }
        endpoint_url: { type: string, format: uri }
        payload: { type: object }
        status: { type: string, enum: [retrying, delivered, failed] }
        attempt_count: { type: integer }
        next_attempt_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        attempts:
          type: array
          items:
            type: object
            required: [attempt_number, response_status, attempted_at]
            properties:
              attempt_number: { type: integer }
              response_status: { type: integer }
              attempted_at: { type: string, format: date-time }
