# ─────────────────────────────────────────────────────────────────────────────
# Zenith Public API — V1 contract (hand-written, source of truth)
#
# This file mirrors server/api/public/v1/_types.ts exactly. A PR that changes
# one MUST change the other. Breaking changes ship as v2 (new file, new path
# prefix); this file is append-only after launch.
#
# Served publicly (no auth) at GET /api/public/v1/openapi.yaml.
# ─────────────────────────────────────────────────────────────────────────────
openapi: 3.1.0

info:
  title: Zenith Public API
  version: 1.0.0
  description: |
    Read-only access to a company's financial data: bank transactions,
    expenses, partners, bank accounts, and yearly report aggregates.

    ## Authentication
    Every request needs BOTH:
    - `Authorization: Bearer zth_live_…` — an API token created in
      **Settings → API tokens**. Shown once at creation; treat it as a secret.
    - `X-Company-Key: <company key>` — the company key shown next to the token.
      One token is permanently bound to one company; the header is an explicit
      guard against cross-company mistakes.

    Tokens are secrets for server-to-server use. The API sends no CORS
    headers — do not call it from browser JavaScript, where the token would be
    exposed.

    ## Conventions
    - Field names are `snake_case`; dates are ISO-8601 (`YYYY-MM-DD` for
      calendar dates, RFC 3339 for timestamps).
    - A field that has no value is an explicit `null` — keys are never omitted.
    - Transaction and expense ids are random strings (partners and bank
      accounts use integer ids). Always use ids exactly as the API returned
      them.
    - Amounts are flat numeric fields next to a `currency` field, like a bank
      statement. `*_eur` fields carry the EUR equivalent — use those when
      summing across currencies.
    - List endpoints return `{ data, next_cursor }`. To get the next page,
      pass `next_cursor` back as `?cursor=` exactly as received; `null` means
      you have everything. A page can be smaller than `limit` even when more
      pages exist — always check `next_cursor`.

    ## Rate limiting
    60 requests/minute per token (token bucket refilled at 1/sec). Every
    response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
    `X-RateLimit-Reset` (unix seconds until fully refilled). A `429` also
    carries `Retry-After` (seconds).

    ## Errors
    Every non-2xx response has the same body:

        { "error": { "code": "<machine_code>", "message": "<human text>", "request_id": "req_…" } }

    Codes: `missing_token`, `invalid_token`, `token_revoked`, `token_expired`
    (401) · `invalid_company_key`, `invalid_request` (400) · `not_found` (404)
    · `rate_limited` (429) · `internal_error` (500). Codes are stable contract;
    messages are not — branch on the code. `request_id` also appears on the
    `X-Request-Id` response header; include it when contacting support.

    Invalid parameters always fail with `400 invalid_request` — filters are
    never silently ignored.

    ## Versioning
    This is V1. Additive changes (new fields, new endpoints) may happen at any
    time — tolerate unknown fields. Breaking changes ship under `/api/public/v2`
    with a documented sunset period for V1.

externalDocs:
  description: Developer documentation — guides, authentication walkthrough, MCP setup.
  url: https://doc.zenith-books.com

servers:
  - url: https://hub.zenith-books.com/api/public/v1
    description: Production.

security:
  - bearerAuth: []
    companyKey: []

tags:
  - name: Getting started
    description: Check that your credentials work and see which company they belong to.
  - name: Transactions
    description: |
      Your bank transactions, as they appear on your accounts — dates, amounts,
      counterparties, IBANs, card numbers — enriched with category names.

      A transaction that was split into pieces (per-category, per-receipt)
      has `is_split: true` and carries the pieces in `children`. The pieces
      always add up to the transaction amount — so when you sum amounts,
      **sum only the transactions themselves**; the children are a breakdown,
      not additional money.
  - name: Expenses
    description: Supplier invoices and cost documents, with line items and payments.
  - name: Partners
    description: Your suppliers and buyers. Search by name here to get the ids used in expense filters.
  - name: Bank accounts
    description: Your bank accounts. Use an account's `id` to filter transactions by account.
  - name: Reports
    description: Ready-made aggregates — one call instead of paginating through raw documents.

paths:
  /me:
    get:
      operationId: getMe
      tags: [Getting started]
      summary: Verify your credentials
      description: |
        Returns the API token you authenticated with and the company it can
        read. Call this first — a `200` means your token and company key are
        set up correctly.
      responses:
        "200":
          description: Your token and its company.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /transactions:
    get:
      operationId: listTransactions
      tags: [Transactions]
      summary: List bank transactions
      description: Newest first. Filter by date range, direction, or bank account.
      parameters:
        - name: date_from
          in: query
          description: Inclusive lower bound on `transaction_date`.
          schema: { type: string, format: date }
        - name: date_to
          in: query
          description: Inclusive upper bound on `transaction_date`.
          schema: { type: string, format: date }
        - name: type
          in: query
          description: incoming = amount > 0; outgoing = amount < 0.
          schema: { type: string, enum: [incoming, outgoing] }
        - name: bank_account_id
          in: query
          description: |
            Only transactions of one bank account — pass the account's `id`
            from GET /bank-accounts. (Transaction rows show the account by
            name in `bank_account`.)
          schema: { type: integer, minimum: 1 }
        - { $ref: "#/components/parameters/cursor" }
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200":
          description: One page of transactions.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Transaction" }
                  next_cursor:
                    type: [string, "null"]
                    description: Pass as `cursor` to get the next page; null when there are no more pages.
              examples:
                splitTransaction:
                  summary: A split card settlement with two allocation pieces
                  value:
                    data:
                      - id: "8f3c2a10-9d4e-4b6a-a1c2-7e5f90d312b2"
                        company_key: "a0b6156c-2f3d-4e5a-9b8c-1d2e3f405191"
                        bank_account: "Sparkasse Poslovni račun"
                        transaction_date: "2026-06-28"
                        amount: -250.0
                        currency: "EUR"
                        amount_eur: -250.0
                        exchange_rate: 1
                        title: "KARTICA 4029****1234"
                        description: "Card settlement June"
                        reference: "SI00 123-456"
                        payer_title: "Zenith d.o.o."
                        receiver_title: "Petrol d.d."
                        payer_iban: "SI56 9999 8888 7777 666"
                        receiver_iban: "SI56 0201 0001 2345 678"
                        payment_card: "4029****1234"
                        category: { id: 7, name: "Vehicle costs" }
                        subcategory: { id: 21, name: "Fuel" }
                        is_split: true
                        children:
                          - id: "c41d9e00-55aa-4f11-8c2d-3b6a7e8f9007"
                            amount: -180.0
                            currency: "EUR"
                            amount_eur: -180.0
                            title: "Fuel Petrol Lj"
                            description: null
                            category: { id: 21, name: "Fuel" }
                            subcategory: { id: null, name: null }
                          - id: "d52eaf11-66bb-4022-9d3e-4c7b8f9a0118"
                            amount: -70.0
                            currency: "EUR"
                            amount_eur: -70.0
                            title: "Vignette"
                            description: null
                            category: { id: 9, name: "Travel" }
                            subcategory: { id: null, name: null }
                    next_cursor: "84123"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /transactions/{id}:
    get:
      operationId: getTransaction
      tags: [Transactions]
      summary: Get one transaction
      description: |
        Works with any transaction id you got from the API — including a
        `transaction_id` from an expense's payments. If that id belongs to a
        piece of a split transaction, you get the full transaction with the
        piece inside `children`, so you always see the complete bank movement.
      parameters:
        - name: id
          in: path
          required: true
          description: A transaction id, e.g. from the list endpoint or an expense payment's `transaction_id`.
          schema: { type: string }
      responses:
        "200":
          description: The transaction.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transaction" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /expenses:
    get:
      operationId: listExpenses
      tags: [Expenses]
      summary: List expenses
      description: |
        Supplier invoices and cost documents, newest entries first. Filter by
        date range, partner, category, or project. For whole-year spend totals
        prefer /reports/yearly-summary — one call instead of many pages.
      parameters:
        - name: date_from
          in: query
          schema: { type: string, format: date }
        - name: date_to
          in: query
          schema: { type: string, format: date }
        - name: partner_id
          in: query
          description: Integer id from GET /partners.
          schema: { type: integer, minimum: 1 }
        - name: category_id
          in: query
          schema: { type: integer, minimum: 1 }
        - name: subcategory_id
          in: query
          schema: { type: integer, minimum: 1 }
        - name: project_id
          in: query
          schema: { type: integer, minimum: 1 }
        - { $ref: "#/components/parameters/cursor" }
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200":
          description: One page of expenses.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Expense" }
                  next_cursor:
                    type: [string, "null"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /expenses/{id}:
    get:
      operationId: getExpense
      tags: [Expenses]
      summary: Get one expense
      description: Full detail including line items and payments.
      parameters:
        - name: id
          in: path
          required: true
          description: An expense id, e.g. from the list endpoint.
          schema: { type: string }
      responses:
        "200":
          description: Full expense detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExpenseDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /bank-accounts:
    get:
      operationId: listBankAccounts
      tags: [Bank accounts]
      summary: List bank accounts
      parameters:
        - { $ref: "#/components/parameters/includeArchived" }
      responses:
        "200":
          description: All bank accounts (not paginated; next_cursor is always null).
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BankAccount" }
                  next_cursor:
                    type: "null"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /bank-accounts/{id}/balances:
    get:
      operationId: listAccountBalances
      tags: [Bank accounts]
      summary: List an account's balances
      description: >
        All stored balance readings for one bank account, newest `date` first.
        Balances are recorded ~daily, so this is a series, not a single number —
        take the first row of the first page for the current balance (or pass
        `limit=1`). An account can have several rows per date — one per currency,
        and opening vs closing. Cursor-paginated: follow `next_cursor` to read
        the full history.
      parameters:
        - name: id
          in: path
          required: true
          description: Integer bank account id, from GET /bank-accounts.
          schema: { type: integer }
        - name: date_from
          in: query
          description: Inclusive lower bound on the balance `date`.
          schema: { type: string, format: date }
        - name: date_to
          in: query
          description: Inclusive upper bound on the balance `date`.
          schema: { type: string, format: date }
        - { $ref: "#/components/parameters/cursor" }
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200":
          description: One page of balances, newest first. When `next_cursor` is non-null, pass it back verbatim for the next page.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AccountBalance" }
                  next_cursor:
                    type: [string, "null"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /partners:
    get:
      operationId: listPartners
      tags: [Partners]
      summary: List partners
      description: Your suppliers and buyers. Use `search` to match by name, VAT id, or registration number.
      parameters:
        - name: search
          in: query
          description: Case-insensitive substring match on short_name, official_name, vat_id, and registration_number.
          schema: { type: string }
        - { $ref: "#/components/parameters/cursor" }
        - { $ref: "#/components/parameters/limit" }
      responses:
        "200":
          description: One page of partners (ordered by id).
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Partner" }
                  next_cursor:
                    type: [string, "null"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /reports/yearly-summary:
    get:
      operationId: getYearlySummary
      tags: [Reports]
      summary: Yearly summary
      description: |
        Monthly expense and income totals by category for one calendar year —
        same calculation as the in-app Reports page. Expense rows are negative,
        income rows positive; amounts are EUR (converted via each document's
        exchange rate). Use this instead of paginating raw documents when you
        only need aggregates.
      parameters:
        - name: year
          in: query
          required: true
          schema: { type: integer, minimum: 2000, maximum: 2100 }
      responses:
        "200":
          description: The yearly summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/YearlySummary" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API token from Settings → API tokens. Format: zth_live_…"
    companyKey:
      type: apiKey
      in: header
      name: X-Company-Key
      description: The company key shown next to the token at creation. Must match the token's bound company.

  parameters:
    cursor:
      name: cursor
      in: query
      description: The `next_cursor` value from the previous response, exactly as received. Leave out for the first page.
      schema: { type: string }
    limit:
      name: limit
      in: query
      description: Page size. Values outside 1–1000 fail with 400.
      schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
    includeArchived:
      name: include_archived
      in: query
      description: Include archived rows. Must be the string "true" or "false".
      schema: { type: string, enum: ["true", "false"], default: "false" }

  headers:
    X-Request-Id:
      description: Per-request id, also embedded in error bodies. Quote it in support requests.
      schema: { type: string }
    X-RateLimit-Limit:
      description: Bucket capacity (requests per minute).
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix seconds at which the bucket is fully refilled.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying (sent on 429).
      schema: { type: integer }

  responses:
    BadRequest:
      description: Invalid parameter or header. Nothing was silently ignored — fix the named field and retry.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: invalid_request
              message: 'date_from must be a valid date in YYYY-MM-DD format, got "junk"'
              request_id: "req_1f2e3d4c5b6a79880911"
    Unauthorized:
      description: Missing, unknown, revoked, or expired token.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: token_expired
              message: "This API token has expired"
              request_id: "req_1f2e3d4c5b6a79880911"
    NotFound:
      description: No such resource in the token's company (or the endpoint does not exist).
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: not_found
              message: "Transaction not found"
              request_id: "req_1f2e3d4c5b6a79880911"
    RateLimited:
      description: Too many requests — wait `Retry-After` seconds and retry.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        Retry-After: { $ref: "#/components/headers/Retry-After" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: rate_limited
              message: "Rate limit exceeded (60 requests/minute per token). Retry after the Retry-After header."
              request_id: "req_1f2e3d4c5b6a79880911"
    InternalError:
      description: Unexpected server-side failure. The body never contains internal details — quote request_id to support.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: internal_error
              message: "Something went wrong on our side. Retry later; include request_id when contacting support."
              request_id: "req_1f2e3d4c5b6a79880911"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code:
              type: string
              enum:
                - missing_token
                - invalid_token
                - token_revoked
                - token_expired
                - invalid_company_key
                - invalid_request
                - not_found
                - rate_limited
                - internal_error
              description: Stable machine code — branch on this, not on message text.
            message:
              type: string
              description: Human-readable explanation. Not stable; may change without notice.
            request_id:
              type: string
              description: Correlates with the X-Request-Id header and server logs.

    Ref:
      type: object
      description: An id + name pair, e.g. a category or partner. Both null when not set.
      required: [id, name]
      properties:
        id: { type: [integer, "null"] }
        name: { type: [string, "null"] }

    Me:
      type: object
      required: [token, company]
      properties:
        token:
          type: object
          required: [id, name, prefix, created_at, last_used_at, expires_at]
          properties:
            id: { type: string }
            name: { type: string }
            prefix: { type: string, description: "First characters of the token, for display (zth_live_a1b2)." }
            created_at: { type: string, format: date-time }
            last_used_at: { type: [string, "null"], format: date-time }
            expires_at: { type: [string, "null"], format: date-time }
        company:
          type: object
          required: [key, name]
          properties:
            key: { type: string, description: "The value to send as X-Company-Key." }
            name: { type: string }

    Transaction:
      type: object
      description: |
        One bank transaction. If it was split into pieces, the pieces are in
        `children` and always add up to `amount` — when summing, use `amount`
        only; the children are its breakdown, not additional money.
      required:
        - id
        - company_key
        - bank_account
        - transaction_date
        - amount
        - currency
        - amount_eur
        - exchange_rate
        - title
        - description
        - reference
        - payer_title
        - receiver_title
        - payer_iban
        - receiver_iban
        - payment_card
        - category
        - subcategory
        - is_split
        - children
      properties:
        id:
          type: string
          description: Unique, permanent id. A random string — use it exactly as returned.
        company_key: { type: string }
        bank_account:
          type: [string, "null"]
          description: Name of the bank account ("unnamed" if the account has no name; null if the transaction isn't tied to an account).
        transaction_date: { type: [string, "null"], format: date, description: "The date the money moved, per your bank." }
        amount: { type: number, description: "Negative = money out, positive = money in. In `currency`." }
        currency: { type: string }
        amount_eur: { type: [number, "null"], description: "The amount in EUR (2 decimals) — use this when summing across currencies." }
        exchange_rate: { type: [number, "null"], description: "EUR per unit of `currency`; null when not known." }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        reference: { type: [string, "null"] }
        payer_title: { type: [string, "null"] }
        receiver_title: { type: [string, "null"] }
        payer_iban: { type: [string, "null"] }
        receiver_iban: { type: [string, "null"] }
        payment_card: { type: [string, "null"], description: "Masked card number when this was paid by card." }
        category: { $ref: "#/components/schemas/Ref" }
        subcategory: { $ref: "#/components/schemas/Ref" }
        is_split: { type: boolean, description: "True when the transaction is split into pieces (see children)." }
        children:
          type: array
          description: The pieces of a split transaction; empty for normal transactions.
          items: { $ref: "#/components/schemas/TransactionChild" }

    TransactionChild:
      type: object
      description: One piece of a split transaction, with its own amount and category. Same currency as the transaction it belongs to.
      required: [id, amount, currency, amount_eur, title, description, category, subcategory]
      properties:
        id: { type: string, description: "Unique id of this piece." }
        amount: { type: number }
        currency: { type: string }
        amount_eur: { type: [number, "null"] }
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }
        category: { $ref: "#/components/schemas/Ref" }
        subcategory: { $ref: "#/components/schemas/Ref" }

    Expense:
      type: object
      required:
        - id
        - company_key
        - invoice_number
        - reference_number
        - expense_date
        - document_date
        - due_date
        - posted_date
        - note
        - partner
        - category
        - subcategory
        - project
        - gross
        - net
        - currency
        - gross_eur
        - net_eur
        - exchange_rate
        - percentage_paid
        - ready_for_accounting
      properties:
        id:
          type: string
          description: Unique, permanent id. A random string — use it exactly as returned.
        company_key: { type: string }
        invoice_number: { type: [string, "null"] }
        reference_number: { type: [string, "null"] }
        expense_date: { type: [string, "null"], format: date }
        document_date: { type: [string, "null"], format: date }
        due_date: { type: [string, "null"], format: date }
        posted_date: { type: [string, "null"], format: date }
        note: { type: [string, "null"] }
        partner: { $ref: "#/components/schemas/Ref" }
        category: { $ref: "#/components/schemas/Ref" }
        subcategory: { $ref: "#/components/schemas/Ref" }
        project:
          oneOf:
            - { $ref: "#/components/schemas/Ref" }
            - { type: "null" }
        gross:
          type: [number, "null"]
          description: Total including VAT, in `currency`. Summed from line items when present, else the document total.
        net:
          type: [number, "null"]
          description: Total excluding VAT, summed from line items; null when the expense has no items.
        currency: { type: [string, "null"] }
        gross_eur:
          type: [number, "null"]
          description: Gross in EUR (2 decimals) — use for cross-currency sums.
        net_eur:
          type: [number, "null"]
          description: Net in EUR (2 decimals).
        exchange_rate: { type: [number, "null"], description: "EUR per unit of the document currency; null when not known." }
        percentage_paid:
          type: [number, "null"]
          description: |
            Fraction of gross settled by linked bank transactions — 0.27 means
            27 % paid, values above 1 mean overpaid. Null when no payments are
            linked or gross is unknown.
        ready_for_accounting: { type: boolean }

    ExpenseItem:
      type: object
      required: [id, expense_id, name, quantity, unit_price, discount_rate, gross, net, vat_rate]
      properties:
        id: { type: integer }
        expense_id: { type: string }
        name: { type: [string, "null"] }
        quantity: { type: [number, "null"] }
        unit_price: { type: [number, "null"] }
        discount_rate:
          type: [number, "null"]
          description: Decimal 0–1, like vat_rate — 0.1 means 10 % off quantity × unit_price. Already reflected in net/gross.
        gross:
          type: number
          description: Line total including VAT, in the expense's currency.
        net:
          type: number
          description: Line total excluding VAT; discount already applied.
        vat_rate: { type: [number, "null"] }

    ExpensePayment:
      type: object
      required: [id, expense_id, amount, currency, amount_eur, payment_date, bank_account_id, transaction_id]
      properties:
        id: { type: integer }
        expense_id: { type: string }
        amount:
          type: number
          description: Absolute amount in `currency` (refund vs payment is not distinguished).
        currency: { type: string }
        amount_eur: { type: [number, "null"] }
        payment_date: { type: [string, "null"], format: date }
        bank_account_id: { type: [integer, "null"] }
        transaction_id:
          type: [string, "null"]
          description: Id of the bank transaction that paid this — fetch it with GET /transactions/{id}.

    ExpenseDetail:
      allOf:
        - { $ref: "#/components/schemas/Expense" }
        - type: object
          required: [items, payments]
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/ExpenseItem" }
            payments:
              type: array
              items: { $ref: "#/components/schemas/ExpensePayment" }

    BankAccount:
      type: object
      required: [id, company_key, iban, name, is_archived]
      properties:
        id: { type: integer, description: "Use as bank_account_id filter on /transactions, or in the id path of /bank-accounts/{id}/balances." }
        company_key: { type: string }
        iban: { type: [string, "null"] }
        name: { type: [string, "null"] }
        is_archived: { type: boolean }

    AccountBalance:
      type: object
      required: [bank_account_id, date, amount, currency, amount_eur, balance_type]
      properties:
        bank_account_id: { type: integer }
        date: { type: string, format: date, description: "Balance date (YYYY-MM-DD)." }
        amount: { type: number, description: "Native balance in `currency`." }
        currency: { type: string, description: "ISO 4217 currency of `amount`." }
        amount_eur: { type: [number, "null"], description: "EUR-normalized balance; null when no FX rate was available at import." }
        balance_type: { type: [string, "null"], description: "Source label, e.g. \"closingBooked\" / \"openingBooked\"; null if unlabelled." }

    Partner:
      type: object
      required:
        - id
        - company_key
        - official_name
        - short_name
        - vat_id
        - registration_number
        - is_company
        - address
        - town
        - postal_code
        - country
        - email
        - phone
        - website
        - ibans
      properties:
        id: { type: integer }
        company_key: { type: string }
        official_name: { type: [string, "null"] }
        short_name: { type: [string, "null"] }
        vat_id: { type: [string, "null"], description: "VAT id (davčna številka)." }
        registration_number: { type: [string, "null"], description: "Company registration number (matična številka)." }
        is_company: { type: boolean, description: "False for individuals." }
        address: { type: [string, "null"] }
        town: { type: [string, "null"] }
        postal_code: { type: [string, "null"] }
        country: { type: [string, "null"] }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        website: { type: [string, "null"] }
        ibans:
          type: array
          description: The partner's known open bank accounts — useful for matching bank transactions to partners.
          items:
            type: object
            required: [iban, bic]
            properties:
              iban: { type: string }
              bic: { type: [string, "null"] }

    YearlySummaryRow:
      type: object
      required: [transaction_type, category, subcategory, total, months]
      properties:
        transaction_type: { type: string, enum: [expense, income] }
        category: { $ref: "#/components/schemas/Ref" }
        subcategory: { $ref: "#/components/schemas/Ref" }
        total: { type: number }
        months:
          type: object
          description: '"YYYY-MM" → amount. All 12 months present; inactive months are 0.'
          additionalProperties: { type: number }

    YearlySummary:
      type: object
      required: [year, company_key, currency, months, rows]
      properties:
        year: { type: integer }
        company_key: { type: string }
        currency: { type: string, const: "EUR" }
        months:
          type: array
          items: { type: string }
        rows:
          type: array
          items: { $ref: "#/components/schemas/YearlySummaryRow" }
