openapi: 3.0.3

# ============================================================================
# ePodatelna24 — Outbox Ingestion API (v1)
#
# Machine-to-machine API for handing UBL/Peppol invoices from an external ERP
# to ePodatelna24 for sending over the Peppol network.
#
# MODEL (v1, deliberately minimal — "fire-and-forget custody"):
#   The ERP hands over one invoice. ePodatelna24 validates it, verifies the
#   biller and recipient, reserves the send fee, and takes CUSTODY of the
#   document. The single response tells the ERP whether custody was taken.
#   The ERP does NOT track delivery — delivery is monitored by the human user
#   inside the ePodatelna24 dashboard. There are no callbacks/webhooks in v1.
#
#   Because the custody response is the ERP's only feedback, EVERYTHING that
#   can be checked synchronously is checked BEFORE custody is granted:
#     - XML safety (no XXE/DTD)
#     - Peppol BIS 3.0 / EN 16931 schematron validation (rule-level errors)
#     - Sender (AccountingSupplierParty DIČ) is a registered, Peppol-active
#       company under the authenticated wallet
#     - Receiver is reachable on the Peppol network (SMP lookup)
#     - Wallet balance covers the send fee
#   A 202 therefore means: "validated, biller active, recipient reachable,
#   fee reserved — we expect to deliver."
#
# AUTH: same scheme ePodatelna24 uses toward its Access Point — an opaque
#   token in the Authorization header with the "Token" scheme:
#       Authorization: Token ep24api_prod_XXXXXXXXXXXXXXXXXXXXXXXX
#   Tokens are issued and revoked in the ePodatelna24 dashboard, at the wallet
#   level, and are wallet-wide: one token can send for any company under that
#   wallet. The correct sender is chosen per-invoice from the DIČ in the XML.
# ============================================================================

info:
  title: ePodatelna24 Outbox Ingestion API
  version: "1.0.0"
  description: |
    Odosielanie faktúr a dobropisov vo formáte UBL 2.1 (Peppol BIS Billing 3.0 /
    EN 16931) z ERP systému do siete Peppol.

    ## Autentifikácia
    API token vydaný v nástenke ePodatelna24 (Peňaženka → API prístup) posielajte
    v hlavičke `Authorization` so schémou **`Token`**:

    ```
    Authorization: Token ep24api_prod_XXXXXXXXXXXXXXXXXXXXXXXX
    ```

    Tokeny v sandboxe majú predponu `ep24api_test_`.

    Tokeny platia **pre celú peňaženku**: jedným tokenom môžete odosielať faktúry
    za ktorúkoľvek spoločnosť registrovanú pod danou peňaženkou. Odosielajúca
    spoločnosť sa určí z DIČ v elemente `AccountingSupplierParty` vnútri každého
    XML dokumentu, takže v samotnej požiadavke netreba uvádzať identifikátor
    spoločnosti.

    ## Určenie odosielateľa
    Odosielajúca spoločnosť sa určí z DIČ nájdeného v bloku odosielateľa. Týmto
    blokom je `cac:AccountingSupplierParty` — okrem samofakturácie (koreňový
    element `SelfBilledInvoice`, alebo `CustomizationID` obsahujúce
    `selfbilling`), kde sa úlohy obracajú a blokom odosielateľa je
    `cac:AccountingCustomerParty`.

    V rámci tohto bloku sa kandidáti skúšajú presne v tomto poradí a vyhráva
    **prvý kandidát, ktorého hodnota končí desiatimi číslicami**:

    1. **Prvý** element `cbc:CompanyID` v poradí, v akom sa vyskytuje v
       dokumente. V dokumente zodpovedajúcom schéme Peppol BIS je to
       `cac:PartyTaxScheme/cbc:CompanyID` (IČ DPH, napr. `SK2020123456`), pretože
       `cac:PartyTaxScheme` v UBL sekvencii predchádza `cac:PartyLegalEntity`.
    2. `cbc:EndpointID`.

    Za DIČ sa považuje posledných desať číslic, takže úvodná predpona krajiny
    (`SK2020123456`) alebo predpona schémy sa odstráni. Dôsledky, ktoré si treba
    uvedomiť:

    - `cbc:CompanyID` má **prednosť pred** `cbc:EndpointID`. Ak sa nezhodujú,
      o tom, ktorej spoločnosti sa odoslanie účtuje, rozhoduje hodnota
      `CompanyID`.
    - `cac:PartyLegalEntity/cbc:CompanyID` býva 8-miestne IČO, ktoré nekončí
      desiatimi číslicami, a preto sa preskočí — ale iba *preto*, že sa nachádza
      až za `PartyTaxScheme`. Skúma sa výlučne prvý `CompanyID`.
    - Identifikátor, ktorý po odstránení predpony nemá desať číslic (napr.
      8-miestne neslovenské IČ DPH), sa nepodarí priradiť a dokument je zamietnutý
      s kódom `422` a pravidlom `EP24-SENDER-DIC`.

    Ak si nie ste istí, na ktorý identifikátor sa váš dokument vyhodnotí, zavolajte
    `POST /api/v1/outbox/documents/validate` a prečítajte si vrátené pole
    `senderDic`.

    ## Idempotencia
    Každé podanie MUSÍ obsahovať hlavičku `Idempotency-Key` (UUID, ktoré vygeneruje
    a uloží váš ERP systém). Opätovné odoslanie **rovnakého kľúča s rovnakým
    dokumentom** vráti pôvodné potvrdenie o prevzatí (`200`) namiesto vytvorenia
    druhého odoslania — je teda bezpečné opakovať pokus po vypršaní časového limitu
    alebo sieťovej chybe. Použitie rovnakého kľúča s **iným dokumentom** je konflikt
    (`409`).

    Kľúče platia v rámci **odosielajúcej spoločnosti**, nie globálne: dve spoločnosti
    môžu nezávisle použiť rovnaké UUID bez kolízie.

    Opakované podanie sa najprv spáruje podľa samotného kľúča a až potom sa porovná
    telo požiadavky. Odpoveď `200` teda nanovo spustí validáciu a kontrolu odosielateľa
    aj príjemcu nad telom, ktoré ste poslali, ale vráti potvrdenie **pôvodného**
    dokumentu — vrátane jeho pôvodného `acceptedAt`. Znova **neoverí** zostatok
    peňaženky a dokument neodošle druhýkrát.

    ## Veľkosť dokumentu
    Telo požiadavky je obmedzené na **4 MB**. Väčšie dokumenty sú zamietnuté s kódom
    `413` a `code: too_large`.

    Tento limit je nižší než vlastné obmedzenie hostingovej platformy (~4,5 MB), aby
    ste vždy dostali odpoveď tohto API vo formáte `application/problem+json`. Telo
    dostatočne veľké na prekročenie platformového limitu je zamietnuté *skôr*, než sa
    dostane k tomuto API — s telom vo formáte čistého textu a bez poľa `code`. Preto
    si každý `JSON.parse` odpovede `413` ošetrite.

    ## Validácia
    Dokumenty sa validujú priamo v procese oproti vloženému, bajt na bajt zhodnému
    schematronu Peppol BIS 3.0 + EN 16931. Nevalidné dokumenty sú zamietnuté s kódom
    `422` a strojovo spracovateľným zoznamom porušených pravidiel (identifikátor
    pravidla, závažnosť, umiestnenie v XML, správa). Na overenie dokumentu počas
    integrácie — bez prevzatia do úschovy a bez spoplatnenia — použite
    `POST /api/v1/outbox/documents/validate`.

    ## Testovacie spúšťače v sandboxe
    Tri zdokumentované výsledky závisia od stavu za behu, ktorý si integrátor nevie
    navodiť na požiadanie — zostatok peňaženky (`402`), stav aktivácie spoločnosti
    (`403 sender_not_active`) a dostupnosť prístupového bodu (`503`) — a k tomu `429`,
    ktoré si vyžaduje zahltenie koncového bodu. **Iba na sandboxovom hostiteľovi**
    pošlite

    ```
    X-Ep24-Simulate: insufficient_funds
    ```

    a daný výsledok sa vynúti. Požiadavka sa inak spracuje normálne (najprv sa overí
    token, `Idempotency-Key` aj telo) a vynútená odpoveď nesie hlavičku
    `X-Ep24-Simulated` s daným kódom. Prípustné hodnoty: `insufficient_funds`,
    `sender_not_active`, `upstream_unavailable`, `rate_limited`. Nerozpoznaná hodnota
    je zamietnutá s `400 invalid_simulation`, nie ticho ignorovaná.

    V produkcii sa táto hlavička **vôbec nečíta** — výsledok nie je možné vynútiť
    oproti skutočnej peňaženke.

    ## Čo v1 NEOBSAHUJE
    - Žiadny koncový bod na zisťovanie stavu doručenia ani webhooky. Doručovanie
      sleduje používateľ v nástenke ePodatelna24. (Môže pribudnúť neskôr bez
      porušenia tohto kontraktu.)
  contact:
    name: ePodatelna24 Integrations
    url: https://www.epodatelna24.sk
  license:
    name: Proprietary
    url: https://www.epodatelna24.sk/podmienky

servers:
  - url: https://www.epodatelna24.sk
    description: Production
  # Sandbox project (no real sends, no charges) — for ERP integration testing.
  # Tokens on this host carry the ep24api_test_ prefix.
  - url: https://epodatelna24-sandbox.vercel.app
    description: Sandbox (integration testing)

security:
  - TokenAuth: []

tags:
  - name: Outbox
    description: Hand invoices to ePodatelna24 for Peppol sending.

paths:
  /api/v1/outbox/documents:
    post:
      tags: [Outbox]
      operationId: submitDocument
      summary: Submit an invoice for sending (takes custody)
      description: |
        Validate a UBL invoice/credit note, verify the biller and recipient,
        reserve the send fee against the wallet, and take custody for Peppol
        delivery. On success (`202`) ePodatelna24 owns the document and will
        deliver it; the ERP does not need to poll.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/SimulateOutcome'
      requestBody:
        required: true
        description: Raw UBL 2.1 XML (Invoice or CreditNote). UTF-8.
        content:
          application/xml:
            schema:
              type: string
              format: xml
            example: |
              <?xml version="1.0" encoding="UTF-8"?>
              <Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
                <!-- ... Peppol BIS 3.0 invoice ... -->
              </Invoice>
      responses:
        '202':
          description: Custody taken. ePodatelna24 will deliver the document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustodyReceipt'
        '200':
          description: |
            Idempotent replay — this `Idempotency-Key` was already accepted for
            this sender company with the same document body. Returns the
            original custody receipt, including its original `acceptedAt`. No
            second send, no second charge.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustodyReceipt'
        '400':
          description: |
            Malformed or unsafe XML (e.g. DTD/entity/XXE rejected), empty body,
            or a missing/invalid `Idempotency-Key` header.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                invalid_xml:
                  value:
                    type: https://www.epodatelna24.sk/errors/invalid_xml
                    title: Invalid XML
                    status: 400
                    code: invalid_xml
                    detail: DOCTYPE declarations are not permitted.
                missing_idempotency_key:
                  value:
                    type: https://www.epodatelna24.sk/errors/missing_idempotency_key
                    title: Missing Idempotency-Key
                    status: 400
                    code: missing_idempotency_key
                    detail: A valid UUID Idempotency-Key header is required.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Wallet balance does not cover the send fee.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              example:
                type: https://www.epodatelna24.sk/errors/insufficient_funds
                title: Insufficient funds
                status: 402
                code: insufficient_funds
                detail: Wallet balance 0.12 EUR is below the send fee 0.22 EUR.
        '403':
          description: |
            The sender DIČ in the document is not a Peppol-active company under
            this wallet. Register/activate the company in ePodatelna24 first.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              examples:
                sender_not_found:
                  value:
                    type: https://www.epodatelna24.sk/errors/sender_not_found
                    title: Sender not found
                    status: 403
                    code: sender_not_found
                    detail: No company with DIČ 1234567890 is registered under this wallet.
                sender_not_active:
                  value:
                    type: https://www.epodatelna24.sk/errors/sender_not_active
                    title: Sender not Peppol-active
                    status: 403
                    code: sender_not_active
                    detail: Company DIČ 1234567890 is registered but not yet Peppol-activated.
        '409':
          description: |
            The `Idempotency-Key` was already used, for this sender company,
            with a **different** document body. Nothing was sent or charged.
            Either resend the original body under this key, or pick a fresh
            key for the new document.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              example:
                type: https://www.epodatelna24.sk/errors/idempotency_conflict
                title: Idempotency key conflict
                status: 409
                code: idempotency_conflict
                detail: This Idempotency-Key was already used with a different document.
        '413':
          description: |
            Document exceeds the 4 MB limit.

            **Guard your parse.** A body over the hosting platform's ~4.5 MB
            request limit is rejected at the edge, before this API runs, with a
            `text/plain` body (`Request Entity Too Large`) and no `code` field.
            Only a `413` produced by this API is `application/problem+json`.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
              example:
                type: https://www.epodatelna24.sk/errors/too_large
                title: Payload too large
                status: 413
                code: too_large
                detail: Document exceeds the 4 MB limit.
        '422':
          description: |
            The document failed Peppol/EN 16931 validation, or the receiver is
            not reachable on the Peppol network. No custody taken, no charge.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ValidationProblem' }
              examples:
                validation_failed:
                  value:
                    type: https://www.epodatelna24.sk/errors/validation_failed
                    title: Peppol validation failed
                    status: 422
                    code: validation_failed
                    detail: The document violates 2 Peppol BIS 3.0 rules.
                    valid: false
                    errors:
                      - rule: PEPPOL-EN16931-R010
                        severity: error
                        location: /Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID
                        message: Buyer electronic address MUST be provided.
                      - rule: BR-CO-15
                        severity: error
                        location: /Invoice/cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount
                        message: Invoice total amount with VAT must equal net + VAT total.
                    warnings: []
                receiver_unreachable:
                  value:
                    type: https://www.epodatelna24.sk/errors/receiver_unreachable
                    title: Receiver not reachable on Peppol
                    status: 422
                    code: receiver_unreachable
                    detail: No SMP registration found for participant 0208:0987654321.
                    valid: true
                    errors: []
                    warnings: []
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Upstream Access Point temporarily unavailable. Retry with backoff.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /api/v1/outbox/documents/validate:
    post:
      tags: [Outbox]
      operationId: validateDocument
      summary: Validate an invoice without taking custody (dry run)
      description: |
        Runs the exact same validation, sender check, and receiver
        reachability check as `submitDocument`, but **does not** take custody,
        reserve funds, or send anything. Intended for ERP integration testing
        and pre-flight checks. Returns `200` with a validation report for both
        valid and invalid documents.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: true
        description: Raw UBL 2.1 XML (Invoice or CreditNote). UTF-8.
        content:
          application/xml:
            schema:
              type: string
              format: xml
      responses:
        '200':
          description: Validation report (the document may be valid or invalid).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationReport' }
              examples:
                valid:
                  value:
                    valid: true
                    senderDic: "1234567890"
                    senderActive: true
                    receiver: { scheme: "0208", value: "0987654321" }
                    receiverReachable: true
                    documentType: Invoice
                    errors: []
                    warnings:
                      - rule: PEPPOL-EN16931-W-R001
                        severity: warning
                        location: /Invoice/cbc:Note
                        message: Consider avoiding free-text notes on the invoice.
                invalid:
                  value:
                    valid: false
                    senderDic: "1234567890"
                    senderActive: true
                    receiver: { scheme: "0208", value: "0987654321" }
                    receiverReachable: true
                    documentType: Invoice
                    errors:
                      - rule: BR-16
                        severity: error
                        location: /Invoice/cac:InvoiceLine
                        message: An Invoice must have at least one Invoice line.
                    warnings: []
        '400':
          description: Malformed or unsafe XML.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

components:

  securitySchemes:
    TokenAuth:
      type: apiKey
      in: header
      name: Authorization
      description: |
        API token with the `Token` scheme. The header value MUST be the literal
        word `Token`, a single space, then the token:
        `Token ep24api_prod_XXXXXXXXXXXXXXXXXXXXXXXX` (production) or
        `Token ep24api_test_XXXXXXXXXXXXXXXXXXXXXXXX` (sandbox).
        Issue and revoke tokens in the ePodatelna24 dashboard under
        Wallet → API access. Tokens are wallet-wide.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A UUID your ERP generates and persists for this document. Re-sending
        the same key with the same document is safe and returns the original
        receipt (`200`). Store it so retries reuse it.

        Reusing a key for a *different* document is rejected (`409`), so derive
        it from the document, not from the attempt. Keys are scoped to the
        sender company resolved from the XML.
      schema:
        type: string
        format: uuid
      example: 5980895f-56b6-4a09-a069-e118a146e622
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: Optional for dry-run validation (no custody is taken).
      schema:
        type: string
        format: uuid
    SimulateOutcome:
      name: X-Ep24-Simulate
      in: header
      required: false
      description: |
        **Sandbox only.** Force a documented outcome that cannot otherwise be
        arranged on demand. Ignored — not even read — on production. The forced
        response echoes `X-Ep24-Simulated: <code>`. An unrecognised value is
        rejected with `400 invalid_simulation`.
      schema:
        type: string
        enum:
          - insufficient_funds
          - sender_not_active
          - upstream_unavailable
          - rate_limited
      example: insufficient_funds

  responses:
    Unauthorized:
      description: Missing or invalid API token.
      headers:
        WWW-Authenticate:
          schema: { type: string }
          description: 'Token'
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://www.epodatelna24.sk/errors/unauthorized
            title: Unauthorized
            status: 401
            code: unauthorized
            detail: The API token is missing, malformed, revoked, or expired.
    RateLimited:
      description: Too many requests. Retry after the indicated delay.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://www.epodatelna24.sk/errors/rate_limited
            title: Too Many Requests
            status: 429
            code: rate_limited
            detail: Request rate exceeded. Retry after 30 seconds.

  schemas:

    CustodyReceipt:
      type: object
      description: |
        Confirmation that ePodatelna24 has taken custody of the document and
        will deliver it over Peppol. This is the ERP's proof of hand-off.
      required: [documentId, status, senderDic, receiver, acceptedAt]
      properties:
        documentId:
          type: string
          format: uuid
          description: ePodatelna24's identifier for the accepted document. Store it for reconciliation.
          example: 0b8c7d1e-2f34-4a56-8b9c-1d2e3f4a5b6c
        status:
          type: string
          enum: [accepted]
          description: Always `accepted` for a custody receipt.
        idempotencyKey:
          type: string
          format: uuid
          description: Echo of the Idempotency-Key that produced this receipt.
        senderDic:
          type: string
          pattern: '^\d{10}$'
          description: Sender tax id (DIČ) resolved from the document.
          example: "1234567890"
        receiver:
          $ref: '#/components/schemas/PeppolParticipant'
        documentType:
          type: string
          enum: [Invoice, CreditNote]
          description: Detected UBL document type.
        acceptedAt:
          type: string
          format: date-time
          description: When custody was taken (UTC).
          example: 2026-07-08T09:15:06Z

    ValidationReport:
      type: object
      description: Result of a dry-run validation (no custody taken).
      required: [valid, errors, warnings]
      properties:
        valid:
          type: boolean
          description: True only if there are zero error-severity issues.
        senderDic:
          type: string
          pattern: '^\d{10}$'
          nullable: true
        senderActive:
          type: boolean
          description: Whether the sender company is registered and Peppol-active under this wallet.
        receiver:
          # OAS 3.0: `nullable` is only meaningful next to a `type`. Beside a
          # bare `allOf` it is ignored by generators and rejected by linters.
          # Express "PeppolParticipant or null" as an explicit anyOf instead.
          anyOf:
            - $ref: '#/components/schemas/PeppolParticipant'
            - type: object
              nullable: true
          description: Null when no receiver participant id could be parsed.
        receiverReachable:
          type: boolean
          description: Whether the receiver was found on the Peppol network (SMP lookup).
        documentType:
          type: string
          enum: [Invoice, CreditNote]
          nullable: true
        errors:
          type: array
          items: { $ref: '#/components/schemas/ValidationIssue' }
        warnings:
          type: array
          items: { $ref: '#/components/schemas/ValidationIssue' }

    ValidationIssue:
      type: object
      description: A single Peppol/EN 16931 rule result.
      required: [rule, severity, message]
      properties:
        rule:
          type: string
          description: Rule identifier (Peppol/EN 16931), e.g. PEPPOL-EN16931-R010 or BR-CO-15.
          example: PEPPOL-EN16931-R010
        severity:
          type: string
          enum: [fatal, error, warning]
        location:
          type: string
          nullable: true
          description: XPath location of the offending node, when available.
          example: /Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID
        message:
          type: string
          description: Human-readable rule description.

    PeppolParticipant:
      type: object
      description: A Peppol participant identifier (scheme + value).
      required: [scheme, value]
      properties:
        scheme:
          type: string
          description: Peppol EAS/ICD scheme, e.g. 0208 (BE), 0245 (SK company register).
          example: "0208"
        value:
          type: string
          description: Identifier value within the scheme.
          example: "0987654321"

    Problem:
      type: object
      description: RFC 7807-style error object (application/problem+json).
      required: [title, status, code]
      properties:
        type:
          type: string
          format: uri
          description: URI identifying the error kind.
        title:
          type: string
          description: Short, human-readable summary.
        status:
          type: integer
          description: HTTP status code.
        code:
          type: string
          description: Stable, machine-readable error code for ERP branching.
          enum:
            - invalid_xml
            - unauthorized
            - insufficient_funds
            - sender_not_found
            - sender_not_active
            - idempotency_conflict
            - missing_idempotency_key
            - too_large
            - validation_failed
            - receiver_unreachable
            - rate_limited
            - upstream_unavailable
            # Sandbox only: unrecognised X-Ep24-Simulate value.
            - invalid_simulation
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence.

    ValidationProblem:
      description: |
        A Problem returned for `422` that also carries the rule-level
        validation results, so the ERP can surface exact failures.
      allOf:
        - $ref: '#/components/schemas/Problem'
        - type: object
          properties:
            valid:
              type: boolean
              example: false
            errors:
              type: array
              items: { $ref: '#/components/schemas/ValidationIssue' }
            warnings:
              type: array
              items: { $ref: '#/components/schemas/ValidationIssue' }
