openapi: 3.1.0
info:
  title: Cloud Billing API
  version: 0.1.0
  description: |
    Versioned HTTP API for tenant data. Authenticate with Bearer JWT (Supabase session or future API keys).
    Mobile/native clients should use the same routes with PKCE auth flows.
servers:
  - url: https://app.example.com/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local
paths:
  /customers:
    get:
      summary: List customers for the current tenant
      tags: [Customers]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Customer"
                  meta:
                    $ref: "#/components/schemas/ListMeta"
        "401":
          description: Unauthorized
        "403":
          description: Authenticated user is not a member of this tenant
        "404":
          description: Tenant not found
    post:
      summary: Create a customer
      tags: [Customers]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CustomerCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Customer"
        "400":
          description: Bad request
        "401":
          description: Unauthorized
        "403":
          description: Authenticated user is not a member of this tenant
        "404":
          description: Tenant not found
  /customers/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: x-tenant-slug
        in: header
        required: true
        schema: { type: string }
    get:
      summary: Fetch one customer
      tags: [Customers]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Customer"
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Not found
    patch:
      summary: Update customer (name, email, company & billing fields)
      tags: [Customers]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CustomerPatch"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Customer"
        "400":
          description: Bad request
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Not found
  /invoices:
    get:
      summary: List invoices for the current tenant
      tags: [Invoices]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
        - name: status
          in: query
          required: false
          description: Repeat or comma-separate allowed values
          schema:
            type: array
            items:
              type: string
              enum: [draft, issued, paid]
          style: form
          explode: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invoice"
                  meta:
                    $ref: "#/components/schemas/ListMeta"
        "400":
          description: Invalid query (e.g. unknown status)
        "401":
          description: Unauthorized
        "403":
          description: Authenticated user is not a member of this tenant
        "404":
          description: Tenant not found
    post:
      summary: Create a single-line invoice (draft or issued)
      tags: [Invoices]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      number: { type: string }
                      total: { type: number }
                      status: { type: string }
        "400":
          description: Bad request
        "401":
          description: Unauthorized
        "403":
          description: Authenticated user is not a member of this tenant
        "404":
          description: Tenant not found
        "409":
          description: Invoice number already exists for this tenant
  /invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: x-tenant-slug
        in: header
        required: true
        schema: { type: string }
    get:
      summary: Fetch one invoice with lines and customer
      tags: [Invoices]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/InvoiceDetail"
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Invoice or tenant not found
    patch:
      summary: Update a draft invoice or finalize it (status issued)
      tags: [Invoices]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoicePatch"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      number: { type: string }
                      total: { type: number }
                      status: { type: string }
        "400":
          description: Bad request
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Invoice not found
        "409":
          description: Conflict (not draft, or duplicate invoice number)
    delete:
      summary: Delete a draft invoice
      tags: [Invoices]
      security:
        - bearerAuth: []
      responses:
        "204":
          description: No content
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Invoice not found
        "409":
          description: Not a draft invoice
  /orders:
    get:
      summary: List sales orders for the current tenant
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - name: status
          in: query
          description: Repeat or comma-separate (draft, confirmed, fulfilled, invoiced, cancelled)
          schema:
            type: array
            items:
              type: string
              enum: [draft, confirmed, fulfilled, invoiced, cancelled]
          style: form
          explode: true
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/SalesOrder"
                  meta:
                    $ref: "#/components/schemas/ListMeta"
        "400":
          description: Invalid query
        "401":
          description: Unauthorized
        "403":
          description: Forbidden
        "404":
          description: Tenant not found
    post:
      summary: Create a multi-line sales order (draft or confirmed)
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SalesOrderCreate"
      responses:
        "201":
          description: Created
        "400":
          description: Bad request
        "409":
          description: Duplicate order number
  /orders/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: x-tenant-slug
        in: header
        required: true
        schema: { type: string }
    get:
      summary: Fetch one sales order with lines and customer
      tags: [Orders]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/SalesOrderDetail"
        "404":
          description: Not found
    patch:
      summary: Update a draft sales order (replace lines and/or header fields)
      tags: [Orders]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SalesOrderPatch"
      responses:
        "200":
          description: OK
        "409":
          description: Not draft or duplicate order number
    delete:
      summary: Delete a draft sales order
      tags: [Orders]
      security:
        - bearerAuth: []
      responses:
        "204":
          description: No content
        "409":
          description: Not a draft order
  /orders/{id}/confirm:
    post:
      summary: Confirm a draft order
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
        "409":
          description: Not draft
  /orders/{id}/fulfill:
    post:
      summary: Fulfill a confirmed order (deducts stock for catalog lines when warehouse_id set)
      description: >-
        On success runs the same side effects as the tenant UI—COGS journal when products have unit_cost,
        webhook sales_order.fulfilled, and an audit row (JWT callers record actor_id; API keys use a null actor).
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderFulfillBody"
      responses:
        "200":
          description: OK
        "409":
          description: Insufficient stock or wrong status
  /orders/{id}/ship:
    post:
      summary: Ship quantities per order line (partial fulfillment); order stays confirmed until fully shipped
      description: >-
        On success emits webhook sales_order.partial_ship while the order stays confirmed, or sales_order.fulfilled
        (+ COGS journal when applicable) when this shipment completes all lines. Writes audit rows; JWT callers set actor_id.
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderShipBody"
      responses:
        "200":
          description: OK; response includes updated order status (confirmed or fulfilled)
        "409":
          description: Insufficient stock, reservation mismatch, or invalid shipment quantities
  /orders/{id}/cancel:
    post:
      summary: Cancel a draft or confirmed order
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
        "409":
          description: Cannot cancel in current status
  /orders/{id}/invoice:
    post:
      summary: Issue an invoice from a confirmed or fulfilled order
      tags: [Orders]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: x-tenant-slug
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderInvoiceBody"
      responses:
        "201":
          description: Created invoice and linked order
        "409":
          description: Duplicate invoice number or wrong order status
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    ListMeta:
      type: object
      properties:
        limit: { type: integer }
        offset: { type: integer }
        status:
          type: array
          items: { type: string }
          description: Echo of status filter when provided
    Customer:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email:
          type: string
          nullable: true
          description: Account / invoice contact email
        credit_balance: { type: number }
        created_at: { type: string, format: date-time }
        legal_name: { type: string, nullable: true }
        company_registration_number: { type: string, nullable: true }
        vat_number: { type: string, nullable: true }
        billing_address_line1: { type: string, nullable: true }
        billing_address_line2: { type: string, nullable: true }
        billing_city: { type: string, nullable: true }
        billing_region: { type: string, nullable: true }
        billing_postal_code: { type: string, nullable: true }
        billing_country: { type: string, nullable: true }
        phone:
          type: string
          nullable: true
          description: Contact phone (shown on invoice bill-to when set)
        account_reference: { type: string, nullable: true }
        shipping_same_as_billing: { type: boolean }
        shipping_name: { type: string, nullable: true }
        shipping_phone: { type: string, nullable: true }
        shipping_email: { type: string, nullable: true }
        shipping_address_line1: { type: string, nullable: true }
        shipping_address_line2: { type: string, nullable: true }
        shipping_city: { type: string, nullable: true }
        shipping_region: { type: string, nullable: true }
        shipping_postal_code: { type: string, nullable: true }
        shipping_country: { type: string, nullable: true }
        default_discount_percent: { type: number, nullable: true }
        customer_group: { type: string, nullable: true }
        preferred_language: { type: string, nullable: true }
        portal_login_enabled: { type: boolean }
        metadata:
          type: object
          additionalProperties: true
    CustomerCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
        email:
          type: string
          description: Optional account / invoice contact email; omit or empty string for no email
        legal_name: { type: string, nullable: true }
        company_registration_number: { type: string, nullable: true }
        vat_number: { type: string, nullable: true }
        billing_address_line1: { type: string, nullable: true }
        billing_address_line2: { type: string, nullable: true }
        billing_city: { type: string, nullable: true }
        billing_region: { type: string, nullable: true }
        billing_postal_code: { type: string, nullable: true }
        billing_country: { type: string, nullable: true }
        phone:
          type: string
          nullable: true
          description: Contact phone (shown on invoice bill-to when set)
        account_reference: { type: string, nullable: true }
        shipping_same_as_billing: { type: boolean }
        shipping_name: { type: string, nullable: true }
        shipping_phone: { type: string, nullable: true }
        shipping_email: { type: string, nullable: true }
        shipping_address_line1: { type: string, nullable: true }
        shipping_address_line2: { type: string, nullable: true }
        shipping_city: { type: string, nullable: true }
        shipping_region: { type: string, nullable: true }
        shipping_postal_code: { type: string, nullable: true }
        shipping_country: { type: string, nullable: true }
        default_discount_percent: { type: number, nullable: true }
        customer_group: { type: string, nullable: true }
        preferred_language: { type: string, nullable: true }
        portal_login_enabled: { type: boolean }
        metadata:
          type: object
          additionalProperties: true
    CustomerPatch:
      type: object
      properties:
        name:
          type: string
        email:
          type: string
          nullable: true
          description: Empty string clears email
        legal_name: { type: string, nullable: true }
        company_registration_number: { type: string, nullable: true }
        vat_number: { type: string, nullable: true }
        billing_address_line1: { type: string, nullable: true }
        billing_address_line2: { type: string, nullable: true }
        billing_city: { type: string, nullable: true }
        billing_region: { type: string, nullable: true }
        billing_postal_code: { type: string, nullable: true }
        billing_country: { type: string, nullable: true }
        phone:
          type: string
          nullable: true
          description: Contact phone (shown on invoice bill-to when set)
        account_reference: { type: string, nullable: true }
        shipping_same_as_billing: { type: boolean }
        shipping_name: { type: string, nullable: true }
        shipping_phone: { type: string, nullable: true }
        shipping_email: { type: string, nullable: true }
        shipping_address_line1: { type: string, nullable: true }
        shipping_address_line2: { type: string, nullable: true }
        shipping_city: { type: string, nullable: true }
        shipping_region: { type: string, nullable: true }
        shipping_postal_code: { type: string, nullable: true }
        shipping_country: { type: string, nullable: true }
        default_discount_percent: { type: number, nullable: true }
        customer_group: { type: string, nullable: true }
        preferred_language: { type: string, nullable: true }
        portal_login_enabled: { type: boolean }
        metadata:
          type: object
          additionalProperties: true
    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: string }
        status: { type: string }
        total: { type: number }
        currency: { type: string }
        created_at: { type: string, format: date-time }
    InvoiceLine:
      type: object
      properties:
        id: { type: string, format: uuid }
        description: { type: string }
        quantity: { type: number }
        unit_price: { type: number }
        tax_rate: { type: number }
        line_total: { type: number }
    InvoiceDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        number: { type: string }
        status: { type: string }
        issued_at: { type: string, format: date, nullable: true }
        due_at: { type: string, format: date, nullable: true }
        subtotal: { type: number }
        tax_total: { type: number }
        total: { type: number }
        currency: { type: string }
        notes: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        customer:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Customer"
        lines:
          type: array
          items:
            $ref: "#/components/schemas/InvoiceLine"
    InvoiceLineCreate:
      type: object
      required: [description]
      properties:
        description: { type: string }
        quantity: { type: number, default: 1 }
        unit_price: { type: number, default: 0 }
        tax_rate: { type: number, default: 0 }
    InvoiceCreate:
      type: object
      required: [customer_id, number]
      properties:
        customer_id:
          type: string
          format: uuid
        number:
          type: string
        line_description:
          type: string
          description: Defaults to "Line 1"
        quantity:
          type: number
          default: 1
        unit_price:
          type: number
          default: 0
        tax_rate:
          type: number
          description: Percentage added on top of qty × unit_price (same as tenant UI)
          default: 0
        status:
          type: string
          enum: [draft, issued]
          default: issued
          description: Draft invoices omit ledger posting until finalized via PATCH
        lines:
          type: array
          maxItems: 50
          description: Multi-line invoice; when present, single-line fields below are ignored
          items:
            $ref: "#/components/schemas/InvoiceLineCreate"
    InvoicePatch:
      type: object
      description: At least one property required. Only draft invoices accept updates.
      properties:
        customer_id:
          type: string
          format: uuid
        number:
          type: string
        line_description:
          type: string
        quantity:
          type: number
        unit_price:
          type: number
        tax_rate:
          type: number
        status:
          type: string
          enum: [draft, issued]
          description: Set to issued to finalize (posts revenue journal)
        lines:
          type: array
          maxItems: 50
          description: Replace all lines on a draft invoice
          items:
            $ref: "#/components/schemas/InvoiceLineCreate"
    SalesOrder:
      type: object
      properties:
        id: { type: string, format: uuid }
        order_number: { type: string }
        status: { type: string }
        total: { type: number }
        currency: { type: string }
        customer_id: { type: string, format: uuid }
        invoice_id: { type: string, format: uuid, nullable: true }
        ordered_at: { type: string, format: date, nullable: true }
        created_at: { type: string, format: date-time }
    SalesOrderLine:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid, nullable: true }
        description: { type: string }
        quantity: { type: number }
        unit_price: { type: number }
        tax_rate: { type: number }
        line_total: { type: number }
    SalesOrderDetail:
      type: object
      properties:
        id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        order_number: { type: string }
        status: { type: string }
        currency: { type: string }
        subtotal: { type: number }
        tax_total: { type: number }
        total: { type: number }
        notes: { type: string, nullable: true }
        invoice_id: { type: string, format: uuid, nullable: true }
        ordered_at: { type: string, format: date, nullable: true }
        created_at: { type: string, format: date-time }
        customer:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Customer"
        lines:
          type: array
          items:
            $ref: "#/components/schemas/SalesOrderLine"
    SalesOrderLineCreate:
      type: object
      required: [description]
      properties:
        description: { type: string }
        quantity: { type: number, default: 1 }
        unit_price: { type: number, default: 0 }
        tax_rate: { type: number, default: 0 }
        product_id: { type: string, format: uuid, nullable: true }
    SalesOrderCreate:
      type: object
      required: [customer_id, order_number, lines]
      properties:
        customer_id: { type: string, format: uuid }
        order_number: { type: string }
        notes: { type: string }
        ordered_at: { type: string, description: ISO date }
        currency: { type: string }
        status:
          type: string
          enum: [draft, confirmed]
          default: draft
        lines:
          type: array
          maxItems: 50
          items:
            $ref: "#/components/schemas/SalesOrderLineCreate"
    SalesOrderPatch:
      type: object
      description: At least one property required; draft orders only.
      properties:
        customer_id: { type: string, format: uuid }
        order_number: { type: string }
        notes: { type: string, nullable: true }
        ordered_at: { type: string }
        currency: { type: string }
        lines:
          type: array
          maxItems: 50
          items:
            $ref: "#/components/schemas/SalesOrderLineCreate"
    OrderFulfillBody:
      type: object
      properties:
        warehouse_id:
          type: string
          format: uuid
          nullable: true
          description: Required when order lines reference catalog products
    OrderShipBody:
      type: object
      required: [shipments]
      properties:
        warehouse_id:
          type: string
          format: uuid
          nullable: true
          description: Override warehouse when the order has no fulfillment warehouse (legacy confirmed orders)
        shipments:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: object
            required: [line_id, quantity]
            properties:
              line_id: { type: string, format: uuid }
              quantity: { type: number, minimum: 0, exclusiveMinimum: true }
    OrderInvoiceBody:
      type: object
      required: [number]
      properties:
        number:
          type: string
          description: Issued invoice number
