openapi: 3.1.0
info:
  title: Billhorse API
  version: 0.1.0-draft
  description: |
    Validate and parse e-invoices (XRechnung, ZUGFeRD/Factur-X, EN 16931 UBL/CII).

    **Quickstart** — validate a file in one call:
    ```bash
    curl -X POST https://api.billhorse.com/v1/validate \
      -H "Authorization: Bearer $BILLHORSE_API_KEY" \
      --data-binary @rechnung.pdf
    ```
    Every finding carries a rule id (e.g. `BR-DE-15`) with a human explanation
    at `https://billhorse.com/regeln/<id>/` (also in EN and FR).
  contact:
    email: hello@billhorse.com
    url: https://billhorse.com
servers:
  - url: https://api.billhorse.com/v1
security:
  - apiKey: []

paths:
  /validate:
    post:
      operationId: validateInvoice
      summary: Validate an e-invoice
      description: |
        Accepts XRechnung/UBL/CII XML or a hybrid ZUGFeRD/Factur-X PDF (raw
        bytes as request body). Returns a validation report with rule findings.
        The file is processed in memory and never stored.
      parameters:
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/xml:
            schema: { type: string, format: binary }
          application/pdf:
            schema: { type: string, format: binary }
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Validation report (also returned when the invoice is invalid — check `valid`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /parse:
    post:
      operationId: parseInvoice
      summary: Parse an e-invoice into normalized JSON
      description: |
        Extracts the semantic invoice model (EN 16931 business terms) from
        XML or hybrid PDF into a stable, format-independent JSON structure —
        the same shape regardless of whether the input was UBL, CII or a
        ZUGFeRD PDF. Includes the validation report.
      parameters:
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/xml:
            schema: { type: string, format: binary }
          application/pdf:
            schema: { type: string, format: binary }
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Normalized invoice plus validation report.
          content:
            application/json:
              schema:
                type: object
                required: [invoice, report]
                properties:
                  invoice: { $ref: "#/components/schemas/Invoice" }
                  report: { $ref: "#/components/schemas/Report" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: Input could not be parsed as an e-invoice (no UBL/CII/ZUGFeRD structure found).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /health:
    get:
      operationId: health
      summary: Service health and engine version
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  engine: { type: string, example: "billhorse-core 0.1.0" }

components:
  parameters:
    Lang:
      name: lang
      in: query
      required: false
      description: |
        Language of finding messages: `en` (default), `de` or `fr`. Rule ids
        and structured fields (`expected`, `actual`, `found`) are language-
        neutral — pass the message straight to your end users.
      schema:
        type: string
        enum: [en, de, fr]
        default: en

  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: API key, issued per tenant. Sandbox keys are free.

  responses:
    BadRequest:
      description: Malformed request (empty body, unsupported content).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    TooLarge:
      description: Body exceeds the size limit (10 MB).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Rate limit exceeded. Retry after the indicated interval.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds. }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      description: RFC 9457 problem details.
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }

    Report:
      type: object
      description: Validation report. `valid` is true iff no finding has severity `error`.
      required: [valid, input, profile, findings, counts, engine]
      properties:
        valid: { type: boolean }
        input: { type: string, enum: [xml, pdf] }
        syntax: { type: string, example: "UBL", description: "UBL or CII (UN/CEFACT)." }
        docType: { type: string, example: "Rechnung" }
        profile: { $ref: "#/components/schemas/ProfileInfo" }
        meta: { $ref: "#/components/schemas/ReportMeta" }
        findings:
          type: array
          items: { $ref: "#/components/schemas/Finding" }
        counts:
          type: object
          properties:
            error: { type: integer }
            warning: { type: integer }
            info: { type: integer }
        engine: { type: string, example: "billhorse-core 0.1.0" }

    ProfileInfo:
      type: object
      required: [label, en16931, xrechnung]
      properties:
        label: { type: string, example: "XRechnung 3.0" }
        customizationId: { type: string }
        en16931: { type: boolean, description: "Profile claims EN 16931 conformance." }
        xrechnung: { type: boolean, description: "German XRechnung rules (BR-DE) were applied." }
        extension: { type: boolean, description: "XRechnung Extension detected." }
        lite: { type: boolean, description: "Reduced ZUGFeRD profile (MINIMUM/BASIC WL)." }

    ReportMeta:
      type: object
      description: Key invoice data extracted for display purposes.
      properties:
        number: { type: string, example: "RE-2026-0815" }
        issueDate: { type: string, example: "2026-06-15" }
        currency: { type: string, example: "EUR" }
        seller: { type: string }
        buyer: { type: string }
        buyerReference: { type: string }
        lineCount: { type: integer }
        lineTotal: { type: string, example: "150.00" }
        taxTotal: { type: string, example: "28.50" }
        grandTotal: { type: string, example: "178.50" }
        due: { type: string, example: "178.50" }

    Finding:
      type: object
      required: [id, severity, message]
      properties:
        id:
          type: string
          example: "BR-DE-15"
          description: |
            Rule id (EN 16931 `BR-*`/`BR-CO-*`, XRechnung `BR-DE-*`, Billhorse
            `BH-*`). Human explanation: `https://billhorse.com/regeln/<id>/`.
        severity: { type: string, enum: [error, warning, info] }
        message: { type: string, description: "Human-readable message in the requested language (`lang` query parameter, default English)." }
        detail: { type: string, description: "Technical detail (e.g. XML parser error)." }
        expected: { type: string, description: "Computed value (arithmetic rules)." }
        actual: { type: string, description: "Stated value (arithmetic rules)." }
        found: { type: string, description: "Offending value." }

    Invoice:
      type: object
      description: Normalized EN 16931 semantic model — identical shape for UBL, CII and PDF inputs.
      required: [docType, seller, buyer, totals, lines, vat]
      properties:
        number: { type: string, description: "BT-1" }
        issueDate: { type: string, description: "BT-2, ISO 8601" }
        typeCode: { type: string, description: "BT-3, UNTDID 1001", example: "380" }
        currency: { type: string, description: "BT-5, ISO 4217" }
        buyerReference: { type: string, description: "BT-10 (Leitweg-ID for German public buyers)" }
        customizationId: { type: string, description: "BT-24" }
        docType: { type: string, enum: [invoice, creditNote] }
        seller: { $ref: "#/components/schemas/Party" }
        buyer: { $ref: "#/components/schemas/Party" }
        hasPaymentMeans: { type: boolean, description: "BG-16 present" }
        totals: { $ref: "#/components/schemas/Totals" }
        lines:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              net: { type: string, description: "BT-131, decimal string" }
        vat:
          type: array
          description: VAT breakdown (BG-23).
          items:
            type: object
            properties:
              category: { type: string, description: "BT-118 (S, Z, E, AE, …)" }
              rate: { type: string, description: "BT-119, percent" }
              taxable: { type: string, description: "BT-116" }
              tax: { type: string, description: "BT-117" }

    Party:
      type: object
      properties:
        name: { type: string, description: "BT-27 / BT-44" }
        country: { type: string, description: "BT-40 / BT-55, ISO 3166-1" }
        vatId: { type: string, description: "BT-31 / BT-48" }
        taxNumber: { type: string, description: "BT-32" }
        legalId: { type: string, description: "BT-30 / BT-47 (e.g. SIREN/SIRET)" }
        legalIdScheme: { type: string, description: "ISO 6523 ICD, e.g. 0002 = SIREN" }
        contactName: { type: string, description: "BT-41" }
        contactPhone: { type: string, description: "BT-42" }
        contactEmail: { type: string, description: "BT-43" }

    Totals:
      type: object
      description: All amounts as decimal strings to avoid float precision issues.
      properties:
        lineTotal: { type: string, description: "BT-106" }
        allowanceTotal: { type: string, description: "BT-107" }
        chargeTotal: { type: string, description: "BT-108" }
        netTotal: { type: string, description: "BT-109" }
        taxTotal: { type: string, description: "BT-110" }
        grandTotal: { type: string, description: "BT-112" }
        prepaid: { type: string, description: "BT-113" }
        rounding: { type: string, description: "BT-114" }
        due: { type: string, description: "BT-115" }
