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" }

  /generate:
    post:
      operationId: generateInvoice
      summary: Generate an e-invoice from a JSON model (preview)
      description: |
        **Preview.** Build a standards-conformant EN 16931 e-invoice from a
        JSON invoice model. Pick the output syntax with `format`: `cii`
        (UN/CEFACT — the syntax embedded in ZUGFeRD/Factur-X), `ubl` (OASIS
        UBL), `peppol` (Peppol BIS Billing 3.0, a UBL CIUS — requires
        seller/buyer electronic addresses to be routable) or `xrechnung`
        (XRechnung 3.0 UBL, the German KOSIT CIUS — needs a seller contact,
        payment means and a buyer reference/Leitweg-ID to pass BR-DE).
        Returns the XML document.

        Quota: this operation counts as **2 request units** (validate/parse
        count as 1).
      parameters:
        - $ref: "#/components/parameters/Format"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceModel" }
      responses:
        "200":
          description: The generated e-invoice XML.
          content:
            application/xml:
              schema: { type: string, format: binary }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /convert:
    post:
      operationId: convertInvoice
      summary: Convert an e-invoice into another syntax (preview)
      description: |
        **Preview.** Parse any e-invoice (UBL, CII, or hybrid ZUGFeRD/Factur-X
        PDF) into the canonical model and re-emit it in the target syntax set
        by `format` (`cii`, `ubl`, `peppol` or `xrechnung`). Conversion
        preserves what the source document carries; fields absent in the
        source are filled with
        defaults.

        Quota: this operation counts as **2 request units** (validate/parse
        count as 1).
      parameters:
        - $ref: "#/components/parameters/Format"
      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: The converted e-invoice XML.
          content:
            application/xml:
              schema: { type: string, format: binary }
        "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" }
                  ruleset: { type: string, example: "2026.07" }

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

    Format:
      name: format
      in: query
      required: false
      description: Target output syntax for generate/convert. `cii` (UN/CEFACT, default), `ubl` (OASIS UBL), `peppol` (Peppol BIS Billing 3.0) or `xrechnung` (XRechnung 3.0 UBL, German KOSIT CIUS).
      schema:
        type: string
        enum: [cii, ubl, peppol, xrechnung]
        default: cii

  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, ruleset]
      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" }
        ruleset:
          type: string
          example: "2026.07"
          description: >-
            Ruleset version (calendar-based YYYY.MM) that produced this report.
            Independent of the engine semver; bumps when validation rules are
            added, changed or tightened. Log it to keep validation outcomes
            reproducible.

    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." }
        peppol: { type: boolean, description: "Peppol BIS Billing 3.0 detected — PEPPOL-EN16931 rules (electronic addresses) were applied. Omitted when false." }
        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" }
        electronicAddress: { type: string, description: "BT-34 / BT-49 (Peppol EndpointID)" }

    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" }

    InvoiceModel:
      type: object
      description: |
        Generation-complete invoice model — the input to `/generate`. All
        amounts are decimal strings; field names are camelCase.
      required: [number, issueDate, typeCode, currency, seller, buyer, lines, vat, totals]
      properties:
        number: { type: string, description: "BT-1" }
        issueDate: { type: string, description: "BT-2, ISO YYYY-MM-DD" }
        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)" }
        dueDate: { type: string, description: "BT-9, ISO YYYY-MM-DD" }
        seller: { $ref: "#/components/schemas/ModelParty" }
        buyer: { $ref: "#/components/schemas/ModelParty" }
        lines:
          type: array
          items: { $ref: "#/components/schemas/ModelLine" }
        vat:
          type: array
          items: { $ref: "#/components/schemas/ModelVat" }
        totals: { $ref: "#/components/schemas/ModelTotals" }
        payment: { $ref: "#/components/schemas/ModelPayment" }

    ModelParty:
      type: object
      required: [name, address]
      properties:
        name: { type: string, description: "BT-27 / BT-44" }
        address: { $ref: "#/components/schemas/ModelAddress" }
        vatId: { type: string, description: "BT-31 / BT-48" }
        taxNumber: { type: string, description: "BT-32" }
        legalId:
          type: array
          description: "[id, schemeId] — BT-30/BT-47 plus optional ISO 6523 scheme id (e.g. 0002 = SIREN)."
          items: { type: string }
        electronicAddress:
          type: array
          description: "[id, schemeId] — BT-34/BT-49 electronic address and its scheme (e.g. EM = email)."
          items: { type: string }
        contact: { $ref: "#/components/schemas/ModelContact" }

    ModelAddress:
      type: object
      required: [country]
      properties:
        line1: { type: string, description: "BT-35 / BT-50" }
        city: { type: string, description: "BT-37 / BT-52" }
        postcode: { type: string, description: "BT-38 / BT-53" }
        country: { type: string, description: "BT-40 / BT-55, ISO 3166-1" }

    ModelContact:
      type: object
      properties:
        name: { type: string, description: "BT-41" }
        phone: { type: string, description: "BT-42" }
        email: { type: string, description: "BT-43" }

    ModelLine:
      type: object
      required: [id, name, quantity, unitCode, netPrice, lineAmount, vatCategory, vatRate]
      properties:
        id: { type: string, description: "BT-126" }
        name: { type: string, description: "BT-153" }
        quantity: { type: string, description: "BT-129, decimal string" }
        unitCode: { type: string, description: "BT-130, UN/ECE Rec 20", example: "C62" }
        netPrice: { type: string, description: "BT-146, decimal string" }
        lineAmount: { type: string, description: "BT-131, decimal string" }
        vatCategory: { type: string, description: "BT-151" }
        vatRate: { type: string, description: "BT-152, percent" }

    ModelVat:
      type: object
      required: [category, rate, taxable, tax]
      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" }
        exemptionReason: { type: string, description: "BT-120, required for exempt categories" }

    ModelTotals:
      type: object
      required: [lineTotal, taxBasis, taxTotal, grandTotal, due]
      properties:
        lineTotal: { type: string, description: "BT-106" }
        allowanceTotal: { type: string, description: "BT-107" }
        chargeTotal: { type: string, description: "BT-108" }
        taxBasis: { type: string, description: "BT-109 (tax exclusive amount)" }
        taxTotal: { type: string, description: "BT-110" }
        grandTotal: { type: string, description: "BT-112" }
        prepaid: { type: string, description: "BT-113" }
        due: { type: string, description: "BT-115" }

    ModelPayment:
      type: object
      required: [typeCode]
      properties:
        typeCode: { type: string, description: "BT-81, UNTDID 4461", example: "58" }
        iban: { type: string, description: "BT-84 payee IBAN" }
