openapi: 3.0.0
info:
  title: 'Ledga Financial Ledger API'
  description: "Double-entry accounting ledger system with multi-tenancy support.\n *\n * ## Features\n * - Double-entry accounting with immutable transactions\n * - Multi-tenant isolation with tenant-scoped data\n * - Multi-currency support (fiat and cryptocurrencies)\n * - Configurable precision and rounding modes\n * - Transaction codes for standardized entry templates\n * - Account sets for grouped reporting\n * - Real-time balance calculations\n *\n * ## Authentication\n * All API endpoints require authentication via Laravel Sanctum bearer tokens.\n *\n * ## Monetary Values\n * - All monetary amounts are stored as integer minor units (pence/cents/satoshis)\n * - API accepts and returns decimal strings for user-facing values\n * - Precision is configurable per ledger (storage_decimals and display_decimals)\n *\n * ## Pagination\n * List endpoints use cursor-based pagination for consistency.\n * See docs/api/PAGINATION.md for details."
  contact:
    email: support@ledga.com
  version: 1.0.0
servers:
  -
    url: 'http://my-default-host.com'
    description: 'Local Development Server'
paths:
  /api/v1/accounts:
    get:
      tags:
        - Accounts
      summary: 'List all accounts in a ledger'
      description: "Display a listing of accounts\n\nUses cursor-based pagination for consistency. See docs/api/PAGINATION.md\nFor hierarchical view, use tree=true parameter."
      operationId: getAccounts
      parameters:
        -
          name: type
          in: query
          description: 'Filter by account type'
          required: false
          schema:
            type: string
            enum:
              - asset
              - liability
              - equity
              - revenue
              - expense
        -
          name: active
          in: query
          description: 'Filter by active status'
          required: false
          schema:
            type: boolean
        -
          name: parent_id
          in: query
          description: 'Filter by parent account UUID'
          required: false
          schema:
            type: string
            format: uuid
        -
          name: search
          in: query
          description: 'Search in code or name'
          required: false
          schema:
            type: string
        -
          name: tree
          in: query
          description: 'Return hierarchical tree structure (non-paginated)'
          required: false
          schema:
            type: boolean
            default: false
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Account'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Ledger not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - Accounts
      summary: 'Create a new account'
      description: 'Store a newly created account'
      operationId: createAccount
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
                - type
                - category
              properties:
                code:
                  description: 'Account code (must be unique within ledger)'
                  type: string
                  maxLength: 36
                  example: '1000'
                name:
                  description: 'Account name'
                  type: string
                  maxLength: 255
                  example: Cash
                type:
                  type: string
                  enum:
                    - asset
                    - liability
                    - equity
                    - revenue
                    - expense
                  example: asset
                category:
                  description: 'Account category — system (internal/GL) or customer (end-user balance)'
                  type: string
                  enum:
                    - system
                    - customer
                  example: system
                normal_balance:
                  description: 'Normal balance side (defaults based on type)'
                  type: string
                  enum:
                    - debit
                    - credit
                  example: debit
                  nullable: true
                parent_code:
                  description: 'Parent account code for hierarchical structure (must exist in same ledger)'
                  type: string
                  maxLength: 36
                  nullable: true
                description:
                  type: string
                  maxLength: 1000
                  nullable: true
                is_active:
                  type: boolean
                  default: true
                is_system:
                  description: 'System accounts cannot be deleted'
                  type: boolean
                  default: false
                metadata:
                  description: 'Arbitrary structured metadata for the account'
                  type: object
                  nullable: true
                enforce_balance_limits:
                  description: 'When true, balance must stay between min_balance and max_balance'
                  type: boolean
                  default: false
                min_balance:
                  description: 'Lower balance limit as a major-unit decimal string. Required when enforce_balance_limits is true. Negative values are permitted for liability accounts.'
                  type: string
                  pattern: '^-?\d+(?:\.\d+)?$'
                  example: '0.00'
                  nullable: true
                max_balance:
                  description: 'Upper balance limit as a major-unit decimal string. Must be >= min_balance.'
                  type: string
                  pattern: '^-?\d+(?:\.\d+)?$'
                  example: '10000.00'
                  nullable: true
              type: object
      responses:
        201:
          description: 'Account created successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Account'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/{accountId}':
    get:
      tags:
        - Accounts
      summary: 'Get a specific account'
      description: 'Display the specified account'
      operationId: getAccount
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Account'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    put:
      tags:
        - Accounts
      summary: 'Update an account'
      description: 'Update the specified account'
      operationId: updateAccount
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              properties:
                parent_id:
                  description: 'Parent account UUID for hierarchical structure'
                  type: string
                  format: uuid
                  nullable: true
                code:
                  description: '4-digit account code'
                  type: string
                  maxLength: 4
                  minLength: 4
                  pattern: '^\d{4}$'
                name:
                  type: string
                  maxLength: 255
                  example: 'Cash - Updated'
                type:
                  type: string
                  enum:
                    - asset
                    - liability
                    - equity
                    - revenue
                    - expense
                normal_balance:
                  type: string
                  enum:
                    - debit
                    - credit
                description:
                  type: string
                  maxLength: 1000
                  nullable: true
                is_active:
                  type: boolean
                metadata:
                  type: object
                  nullable: true
                enforce_balance_limits:
                  description: 'Enable trigger-enforced min/max balance limits'
                  type: boolean
                min_balance:
                  description: 'Lower balance limit as a major-unit decimal string. Negative values are permitted for liability accounts.'
                  type: string
                  pattern: '^-?\d+(?:\.\d+)?$'
                  example: '0.00'
                  nullable: true
                max_balance:
                  description: 'Upper balance limit as a major-unit decimal string. Must be >= min_balance.'
                  type: string
                  pattern: '^-?\d+(?:\.\d+)?$'
                  example: '10000.00'
                  nullable: true
              type: object
      responses:
        200:
          description: 'Account updated successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Account'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    delete:
      tags:
        - Accounts
      summary: 'Delete an account'
      description: 'Remove the specified account'
      operationId: deleteAccount
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Account deleted successfully'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/{accountId}/balance-history':
    get:
      tags:
        - Accounts
      summary: 'Get account balance history'
      description: 'Get account balance history'
      operationId: getAccountBalanceHistory
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
        -
          name: start_date
          in: query
          description: 'Start date for history'
          required: false
          schema:
            type: string
            format: date
        -
          name: end_date
          in: query
          description: 'End date for history'
          required: false
          schema:
            type: string
            format: date
      responses:
        200:
          description: 'Balance history retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account:
                            type: object
                          period:
                            type: object
                          history:
                            type: array
                            items:
                              type: object
                          ending_balance:
                            type: number
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/accounts/initialize-defaults:
    post:
      tags:
        - Accounts
      summary: 'Initialize default chart of accounts for a ledger'
      description: 'Initialize default chart of accounts'
      operationId: initializeDefaultAccounts
      responses:
        200:
          description: 'Default accounts created successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          accounts_count:
                            type: integer
                            example: 15
                        type: object
                    type: object
        400:
          description: 'Ledger already has accounts'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/{accountId}/entries':
    get:
      tags:
        - Accounts
      summary: 'Get all entries for an account with rolling balance'
      description: "Get all entries for an account with rolling balance calculation\n\nReturns entries in chronological order by effective_date with a calculated\nbalance_after field showing the running balance after each entry."
      operationId: getAccountEntries
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
        -
          name: start_date
          in: query
          description: 'Filter entries from this date (inclusive)'
          required: false
          schema:
            type: string
            format: date
            example: '2025-01-01'
        -
          name: end_date
          in: query
          description: 'Filter entries up to this date (inclusive)'
          required: false
          schema:
            type: string
            format: date
            example: '2025-12-31'
        -
          name: layer
          in: query
          description: 'Filter by transaction layer'
          required: false
          schema:
            type: string
            enum:
              - settled
              - pending
              - encumbrance
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Entries retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          properties:
                            id:
                              type: string
                              format: uuid
                            transaction_id:
                              type: string
                              format: uuid
                            account_id:
                              type: string
                              format: uuid
                            account_code:
                              type: string
                              example: '1000'
                            type:
                              type: string
                              enum:
                                - debit
                                - credit
                            amount:
                              type: string
                              example: '100.00'
                            description:
                              type: string
                              nullable: true
                            layer:
                              type: string
                              enum:
                                - settled
                                - pending
                                - encumbrance
                            effective_date:
                              type: string
                              format: date
                            balance_after:
                              description: 'Running balance after this entry'
                              type: string
                              example: '1000.00'
                            transaction:
                              properties:
                                reference:
                                  type: string
                                description:
                                  type: string
                              type: object
                          type: object
                      meta:
                        properties:
                          account:
                            properties:
                              id:
                                type: string
                                format: uuid
                              code:
                                type: string
                              name:
                                type: string
                              normal_balance:
                                type: string
                                enum:
                                  - debit
                                  - credit
                            type: object
                          pagination:
                            $ref: '#/components/schemas/PaginationMeta'
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/code/{code}':
    get:
      tags:
        - Accounts
      summary: 'Get a specific account by its code'
      description: 'Display the specified account by code'
      operationId: getAccountByCode
      parameters:
        -
          name: code
          in: path
          description: 'Account code (customer-defined identifier)'
          required: true
          schema:
            type: string
            example: '1000'
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Account'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/code/{code}/balance':
    get:
      tags:
        - Accounts
      summary: 'Get account balance by code with layer details'
      description: 'Get account balance by code with layer details'
      operationId: getAccountBalanceByCode
      parameters:
        -
          name: code
          in: path
          description: 'Account code (customer-defined identifier)'
          required: true
          schema:
            type: string
            example: '1000'
        -
          name: as_of_date
          in: query
          description: 'Calculate balance as of this date'
          required: false
          schema:
            type: string
            format: date
        -
          name: layer
          in: query
          description: 'Filter by specific layer'
          required: false
          schema:
            type: string
            enum:
              - settled
              - pending
              - encumbrance
      responses:
        200:
          description: 'Balance retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account_id:
                            type: string
                            format: uuid
                          account_code:
                            type: string
                            example: '1000'
                          account_name:
                            type: string
                            example: Cash
                          account_type:
                            type: string
                            example: asset
                          normal_balance:
                            type: string
                            example: debit
                          balances:
                            properties:
                              settled:
                                description: 'Confirmed cleared funds - what you can spend'
                                type: string
                                example: '1000.00'
                              pending:
                                description: 'Unconfirmed transactions (not yet spendable)'
                                type: string
                                example: '50.00'
                              overdue:
                                description: 'Encumbrances past their due date'
                                type: string
                                example: '20.00'
                              future:
                                description: 'Encumbrances due today or later'
                                type: string
                                example: '80.00'
                            type: object
                          layer_details:
                            description: 'Detailed breakdown per layer'
                            type: object
                          currency:
                            type: string
                            example: GBP
                          as_of_date:
                            type: string
                            format: date
                            example: '2025-01-15'
                          calculated_at:
                            type: string
                            format: date-time
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/{accountId}/balance':
    get:
      tags:
        - Accounts
      summary: 'Get account balance with layer details'
      description: "Get account balance with layer details\n\nReturns balances for each accounting layer (settled, pending) plus\nencumbrance breakdown (overdue, future). Per issue #86, the \"available\"\nbalance concept was removed - only settled funds are spendable."
      operationId: getAccountBalance
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
        -
          name: as_of_date
          in: query
          description: 'Calculate balance as of this date (Y-m-d format)'
          required: false
          schema:
            type: string
            format: date
        -
          name: layer
          in: query
          description: 'Filter by specific layer (informational only, all layers are always returned)'
          required: false
          schema:
            type: string
            enum:
              - settled
              - pending
              - encumbrance
      responses:
        200:
          description: 'Balance retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account_id:
                            type: string
                            format: uuid
                            example: 01234567-89ab-cdef-0123-456789abcdef
                          account_code:
                            type: string
                            example: '1000'
                          account_name:
                            type: string
                            example: Cash
                          account_type:
                            type: string
                            example: asset
                          normal_balance:
                            type: string
                            example: debit
                          balances:
                            properties:
                              settled:
                                description: 'Confirmed cleared funds - what you can spend'
                                type: string
                                example: '1000.00'
                              pending:
                                description: 'Unconfirmed transactions (not yet spendable)'
                                type: string
                                example: '50.00'
                              overdue:
                                description: 'Encumbrances past their due date'
                                type: string
                                example: '20.00'
                              future:
                                description: 'Encumbrances due today or later'
                                type: string
                                example: '80.00'
                            type: object
                          layer_details:
                            description: 'Detailed breakdown per layer'
                            type: object
                          currency:
                            type: string
                            example: GBP
                          as_of_date:
                            type: string
                            format: date
                            example: '2025-01-15'
                          calculated_at:
                            type: string
                            format: date-time
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/{accountId}/balance/point-in-time':
    get:
      tags:
        - Accounts
      summary: 'Get an account point-in-time balance for a single layer'
      description: "Get an account's point-in-time balance for a single layer (issue #118).\n\nRecomputes the balance at an exact instant from the immutable `entries`\ntable via {@see AccountBalanceQueryService::asOf()}. This is a distinct\nsurface from getBalance(): it returns ONE layer reconstructed at `as_of`,\nnever the layered snapshot."
      operationId: getAccountPointInTimeBalance
      parameters:
        -
          name: accountId
          in: path
          description: 'Account UUID'
          required: true
          schema:
            type: string
            format: uuid
        -
          name: as_of
          in: query
          description: 'Point-in-time instant: ISO 8601 with an explicit timezone (Z or ±HH:MM) at SECOND precision, e.g. 2026-04-15T14:07:32Z. The balance is recomputed from the immutable entries as of this instant; ledger timestamps are second-granular.'
          required: true
          schema:
            type: string
            format: date-time
            pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$'
            example: '2026-04-15T14:07:32Z'
        -
          name: layer
          in: query
          description: 'The single layer to reconstruct (defaults to settled).'
          required: false
          schema:
            type: string
            default: settled
            enum:
              - settled
              - pending
              - encumbrance
      responses:
        200:
          description: 'Point-in-time balance retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account_id:
                            type: string
                            format: uuid
                            example: 01234567-89ab-cdef-0123-456789abcdef
                          account_code:
                            type: string
                            example: '1000'
                          layer:
                            type: string
                            enum:
                              - settled
                              - pending
                              - encumbrance
                            example: settled
                          balance:
                            description: 'Single-layer balance reconstructed at `as_of`, as a major-unit decimal string.'
                            type: string
                            example: '150.00'
                          currency:
                            type: string
                            example: GBP
                          as_of:
                            description: 'The requested instant, normalised to UTC.'
                            type: string
                            format: date-time
                            example: '2026-04-15T14:07:32+00:00'
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/accounts/code/{code}/balance/point-in-time':
    get:
      tags:
        - Accounts
      summary: 'Get an account point-in-time balance for a single layer, by code'
      description: "Get an account's point-in-time balance for a single layer, by code.\n\nBy-code sibling of {@see getPointInTimeBalance()} (mirrors the\ngetBalanceByCode → getBalance delegation)."
      operationId: getAccountPointInTimeBalanceByCode
      parameters:
        -
          name: code
          in: path
          description: 'Account code (customer-defined identifier)'
          required: true
          schema:
            type: string
            example: '1000'
        -
          name: as_of
          in: query
          description: 'Point-in-time instant: ISO 8601 with an explicit timezone (Z or ±HH:MM) at SECOND precision, e.g. 2026-04-15T14:07:32Z. The balance is recomputed from the immutable entries as of this instant; ledger timestamps are second-granular.'
          required: true
          schema:
            type: string
            format: date-time
            pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$'
            example: '2026-04-15T14:07:32Z'
        -
          name: layer
          in: query
          description: 'The single layer to reconstruct (defaults to settled).'
          required: false
          schema:
            type: string
            default: settled
            enum:
              - settled
              - pending
              - encumbrance
      responses:
        200:
          description: 'Point-in-time balance retrieved successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account_id:
                            type: string
                            format: uuid
                            example: 01234567-89ab-cdef-0123-456789abcdef
                          account_code:
                            type: string
                            example: '1000'
                          layer:
                            type: string
                            enum:
                              - settled
                              - pending
                              - encumbrance
                            example: settled
                          balance:
                            description: 'Single-layer balance reconstructed at `as_of`, as a major-unit decimal string.'
                            type: string
                            example: '150.00'
                          currency:
                            type: string
                            example: GBP
                          as_of:
                            description: 'The requested instant, normalised to UTC.'
                            type: string
                            format: date-time
                            example: '2026-04-15T14:07:32+00:00'
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/account-sets:
    get:
      tags:
        - 'Account Sets'
      summary: 'List all account sets'
      description: "Display a listing of account sets\n\nUses cursor-based pagination for consistency. See docs/api/PAGINATION.md"
      operationId: getAccountSets
      parameters:
        -
          name: search
          in: query
          description: 'Search in code or name'
          required: false
          schema:
            type: string
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/AccountSet'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - 'Account Sets'
      summary: 'Create a new account set'
      description: 'Store a newly created account set'
      operationId: createAccountSet
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
              properties:
                code:
                  description: 'Unique code for this set'
                  type: string
                  example: OPERATING_EXPENSES
                name:
                  description: 'Display name'
                  type: string
                  example: 'Operating Expenses'
                description:
                  type: string
                  nullable: true
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        201:
          description: 'Account set created'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/AccountSet'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/account-sets/{accountSetId}':
    get:
      tags:
        - 'Account Sets'
      summary: 'Get a specific account set'
      description: 'Display the specified account set'
      operationId: getAccountSet
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/AccountSet'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    put:
      tags:
        - 'Account Sets'
      summary: 'Update an account set'
      description: 'Update the specified account set'
      operationId: updateAccountSet
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
              properties:
                code:
                  type: string
                name:
                  type: string
                description:
                  type: string
                  nullable: true
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        200:
          description: 'Account set updated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/AccountSet'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    delete:
      tags:
        - 'Account Sets'
      summary: 'Delete an account set'
      description: 'Remove the specified account set'
      operationId: deleteAccountSet
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Account set deleted'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          id:
                            type: string
                            format: uuid
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/account-sets/{accountSetId}/members':
    post:
      tags:
        - 'Account Sets'
      summary: 'Add an account or nested set to this account set'
      description: 'Add a member to the account set'
      operationId: addAccountSetMember
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - member_type
                - member_id
              properties:
                member_type:
                  type: string
                  enum:
                    - account
                    - account_set
                  example: account
                member_id:
                  description: 'UUID of account or account set'
                  type: string
                  format: uuid
              type: object
      responses:
        200:
          description: 'Member added'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          member_type:
                            type: string
                            enum:
                              - account
                              - account_set
                          member_id:
                            type: string
                            format: uuid
                        type: object
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    delete:
      tags:
        - 'Account Sets'
      summary: 'Remove an account or nested set from this account set'
      description: 'Remove a member from the account set'
      operationId: removeAccountSetMember
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - member_type
                - member_id
              properties:
                member_type:
                  type: string
                  enum:
                    - account
                    - account_set
                member_id:
                  type: string
                  format: uuid
              type: object
      responses:
        200:
          description: 'Member removed'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          member_type:
                            type: string
                            enum:
                              - account
                              - account_set
                          member_id:
                            type: string
                            format: uuid
                        type: object
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set or member not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/account-sets/{accountSetId}/balance':
    get:
      tags:
        - 'Account Sets'
      summary: 'Get aggregated balance for all accounts in this set'
      description: 'Get aggregated balance for the account set'
      operationId: getAccountSetBalance
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Balance retrieved'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          account_set_id:
                            type: string
                            format: uuid
                          account_set_code:
                            type: string
                          account_set_name:
                            type: string
                          total_balance:
                            type: string
                            example: '1000.00'
                          currency:
                            type: string
                            example: USD
                          account_count:
                            type: integer
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/account-sets/{accountSetId}/accounts':
    get:
      tags:
        - 'Account Sets'
      summary: 'Get all accounts in this set (recursively includes nested sets)'
      description: 'Get all accounts in the account set (recursively)'
      operationId: getAccountSetAccounts
      parameters:
        -
          name: accountSetId
          in: path
          description: 'Account Set UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Accounts retrieved'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Account'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Account set not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/attribute-definitions:
    get:
      tags:
        - 'Attribute Catalog'
      summary: "List the tenant's searchable attribute definitions (catalog)"
      description: "List the tenant's catalog."
      operationId: listAttributeDefinitions
      parameters:
        -
          name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Opaque keyset cursor returned by this endpoint. A malformed or foreign cursor is rejected as 422.'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/AttributeDefinition'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - 'Attribute Catalog'
      summary: 'Declare a searchable attribute key'
      description: 'Declare a key (required to opt a tenant into strict mode). Applies the same key guard rails as the write path (charset, reserved-name, distinct-key cap). Requires the `attributes:admin` capability on the API key (this endpoint mutates tenant-wide governance state); a key without it receives 403 ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED.'
      operationId: declareAttributeDefinition
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - key
              properties:
                key:
                  description: 'Lowercase snake_case. Must not be reserved or exceed the tenant distinct-key cap.'
                  type: string
                  maxLength: 64
                  example: customer_id
                label:
                  type: string
                  example: 'Customer ID'
                  nullable: true
                indexed:
                  type: boolean
                  default: true
              type: object
      responses:
        201:
          description: 'Attribute definition declared'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/AttributeDefinition'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: "Forbidden — missing the required 'attributes:admin' capability (ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED)"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Key already declared'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Guard-rail violation (INVALID_ATTRIBUTE_KEY / RESERVED_ATTRIBUTE_KEY / ATTRIBUTE_KEY_CAP_EXCEEDED)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/attribute-definitions/{key}':
    patch:
      tags:
        - 'Attribute Catalog'
      summary: "Update an attribute definition's label / indexed flag"
      description: "Update a declared key's mutable metadata (label / indexed); the key itself is immutable. Requires the `attributes:admin` capability on the API key; a key without it receives 403 ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED."
      operationId: updateAttributeDefinition
      parameters:
        -
          name: key
          in: path
          required: true
          schema:
            type: string
            example: customer_id
      requestBody:
        required: false
        content:
          application/json:
            schema:
              properties:
                label:
                  type: string
                  example: 'Customer ID'
                  nullable: true
                indexed:
                  type: boolean
                  example: false
              type: object
      responses:
        200:
          description: 'Attribute definition updated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/AttributeDefinition'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: "Forbidden — missing the required 'attributes:admin' capability (ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED)"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Attribute definition not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/attribute-settings:
    get:
      tags:
        - 'Attribute Catalog'
      summary: "Read the tenant's attribute settings (strict mode)"
      description: "Read the tenant's attribute settings."
      operationId: getAttributeSettings
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          strict_mode:
                            type: boolean
                            example: false
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    put:
      tags:
        - 'Attribute Catalog'
      summary: "Toggle the tenant's attribute strict mode"
      description: "Toggle the tenant's strict mode. Requires the `attributes:admin` capability on the API key (strict mode is tenant-wide governance state); a key without it receives 403 ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED."
      operationId: updateAttributeSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - strict_mode
              properties:
                strict_mode:
                  description: 'When true, only declared keys are accepted on the write path.'
                  type: boolean
                  example: true
              type: object
      responses:
        200:
          description: 'Settings updated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          strict_mode:
                            type: boolean
                            example: true
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: "Forbidden — missing the required 'attributes:admin' capability (ATTRIBUTE_ADMIN_CAPABILITY_REQUIRED)"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/fx/rates:
    get:
      tags:
        - FX
      summary: 'List stored exchange rates for the tenant'
      description: "List the tenant's stored rates (newest first), optionally filtered by pair/source."
      operationId: listExchangeRates
      parameters:
        -
          name: base
          in: query
          description: 'Filter by base currency.'
          required: false
          schema:
            type: string
            example: EUR
        -
          name: quote
          in: query
          description: 'Filter by quote currency.'
          required: false
          schema:
            type: string
            example: USD
        -
          name: source
          in: query
          description: 'Filter by provenance (manual, ecb, …).'
          required: false
          schema:
            type: string
            example: manual
        -
          name: limit
          in: query
          description: 'Items per page (max 100).'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination.'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/ExchangeRate'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - FX
      summary: 'Store a manual exchange-rate override'
      description: 'Store a MANUAL rate override for the tenant.'
      operationId: storeExchangeRate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - base_currency
                - quote_currency
                - rate
              properties:
                base_currency:
                  type: string
                  example: EUR
                quote_currency:
                  type: string
                  example: USD
                rate:
                  description: '1 base = rate quote (positive decimal). The inverse is derived.'
                  type: string
                  example: '1.081'
                effective_at:
                  description: 'Defaults to now.'
                  type: string
                  format: date-time
                  nullable: true
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
              type: object
      responses:
        201:
          description: 'Rate stored'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/ExchangeRate'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'A rate already exists for this pair at that effective time'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/fx/rates/{from}/{to}':
    get:
      tags:
        - FX
      summary: 'Get the current resolved rate for a pair'
      description: "The current resolved rate for a pair (manual override → live provider → DB\nfallback, per the stale-rate policy)."
      operationId: getCurrentExchangeRate
      parameters:
        -
          name: from
          in: path
          description: 'Base currency.'
          required: true
          schema:
            type: string
            example: EUR
        -
          name: to
          in: path
          description: 'Quote currency.'
          required: true
          schema:
            type: string
            example: USD
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/ExchangeRateQuote'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Invalid currency, or no rate available / rate stale'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/fx/rates/{from}/{to}/history':
    get:
      tags:
        - FX
      summary: 'List stored rate history for a pair'
      description: "The tenant's stored rate history for a pair (newest first)."
      operationId: getExchangeRateHistory
      parameters:
        -
          name: from
          in: path
          description: 'Base currency.'
          required: true
          schema:
            type: string
            example: EUR
        -
          name: to
          in: path
          description: 'Quote currency.'
          required: true
          schema:
            type: string
            example: USD
        -
          name: limit
          in: query
          description: 'Items per page (max 100).'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination.'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/ExchangeRate'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Invalid currency'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/fx/convert:
    get:
      tags:
        - FX
      summary: 'Convert an amount between two currencies (calculator, posts nothing)'
      description: "Convert an amount between two currencies at the current resolved rate. Posts\nnothing."
      operationId: convertCurrency
      parameters:
        -
          name: from
          in: query
          description: 'Source currency.'
          required: true
          schema:
            type: string
            example: USD
        -
          name: to
          in: query
          description: 'Target currency.'
          required: true
          schema:
            type: string
            example: EUR
        -
          name: amount
          in: query
          description: 'Amount in the source currency (major units).'
          required: true
          schema:
            type: string
            example: '100.00'
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/FxConversion'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error, or no rate available / rate stale'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/journals:
    get:
      tags:
        - Journals
      summary: 'List all journals'
      description: 'Display a listing of journals for a ledger.'
      operationId: getJournals
      parameters:
        -
          name: status
          in: query
          description: 'Filter by status'
          required: false
          schema:
            type: string
            enum:
              - active
              - closed
        -
          name: search
          in: query
          description: 'Search in code or name'
          required: false
          schema:
            type: string
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Journal'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - Journals
      summary: 'Create a new journal'
      description: 'Store a newly created journal.'
      operationId: createJournal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
              properties:
                code:
                  description: 'Unique code within ledger'
                  type: string
                  example: SALES
                name:
                  type: string
                  example: 'Sales Journal'
                description:
                  type: string
                  nullable: true
                status:
                  type: string
                  default: active
                  enum:
                    - active
                    - closed
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        201:
          description: 'Journal created'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Journal'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Journal code already exists'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/journals/{journalId}':
    get:
      tags:
        - Journals
      summary: 'Get a specific journal'
      description: 'Display the specified journal.'
      operationId: getJournal
      parameters:
        -
          name: journalId
          in: path
          description: 'Journal UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Journal'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Journal not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    put:
      tags:
        - Journals
      summary: 'Update a journal'
      description: 'Update the specified journal.'
      operationId: updateJournal
      parameters:
        -
          name: journalId
          in: path
          description: 'Journal UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              properties:
                code:
                  type: string
                name:
                  type: string
                description:
                  type: string
                  nullable: true
                status:
                  type: string
                  enum:
                    - active
                    - closed
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        200:
          description: 'Journal updated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Journal'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: 'Forbidden (cannot rename DEFAULT journal)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Journal not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Journal code already exists'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    delete:
      tags:
        - Journals
      summary: 'Delete a journal'
      description: 'Remove the specified journal.'
      operationId: deleteJournal
      parameters:
        -
          name: journalId
          in: path
          description: 'Journal UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        204:
          description: 'Journal deleted'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: 'Forbidden (cannot delete DEFAULT journal)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Journal not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Cannot delete journal with existing transactions'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/journals/{journalId}/statistics':
    get:
      tags:
        - Journals
      summary: 'Get journal statistics'
      description: 'Get journal statistics.'
      operationId: getJournalStatistics
      parameters:
        -
          name: journalId
          in: path
          description: 'Journal UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Statistics retrieved'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          total_transactions:
                            type: integer
                          posted_transactions:
                            type: integer
                          pending_transactions:
                            type: integer
                          reversed_transactions:
                            type: integer
                          last_transaction_date:
                            type: string
                            format: date-time
                            nullable: true
                          total_accounts_affected:
                            type: integer
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Journal not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/strings:
    get:
      tags:
        - Strings
      summary: 'List ledger strings for the tenant'
      description: "List the tenant's strings."
      operationId: listStrings
      parameters:
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/LedgerString'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - Strings
      summary: 'Create a ledger string'
      description: 'Create a string for the tenant.'
      operationId: createString
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
              properties:
                code:
                  description: 'Unique within the tenant. Letters, numbers, dashes and underscores.'
                  type: string
                  maxLength: 50
                  example: treasury-fx
                name:
                  type: string
                  maxLength: 255
                  example: 'Treasury FX group'
                description:
                  type: string
                  maxLength: 1000
                  nullable: true
                is_active:
                  type: boolean
                  default: true
              type: object
      responses:
        201:
          description: 'String created successfully'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/LedgerString'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/strings/{code}':
    get:
      tags:
        - Strings
      summary: 'Get a ledger string with its member ledgers'
      description: 'Show a single string and its member ledgers.'
      operationId: getString
      parameters:
        -
          name: code
          in: path
          description: 'String code (unique within the tenant)'
          required: true
          schema:
            type: string
            example: treasury-fx
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/LedgerString'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'String not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/reports/trial-balance:
    get:
      tags:
        - Reports
      summary: 'Get trial balance report'
      description: 'Returns all system accounts with their debit/credit balances, plus a rollup of all user accounts. Ensures debits equal credits.'
      operationId: getTrialBalance
      parameters:
        -
          name: layer
          in: query
          description: 'Transaction layer to report on'
          required: false
          schema:
            type: string
            default: settled
            enum:
              - settled
              - pending
              - encumbrance
        -
          name: as_of_date
          in: query
          description: 'Report balances as of this date'
          required: false
          schema:
            type: string
            format: date
      responses:
        200:
          description: 'Trial balance report'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          as_of_date:
                            type: string
                            format: date
                          layer:
                            type: string
                          accounts:
                            type: array
                            items:
                              type: object
                          totals:
                            properties:
                              total_debits:
                                type: string
                                example: '10000.00'
                              total_credits:
                                type: string
                                example: '10000.00'
                              is_balanced:
                                type: boolean
                            type: object
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/reports/income-statement:
    get:
      tags:
        - Reports
      summary: 'Get income statement report'
      description: 'Returns revenue and expense accounts for a date range, grouped by system accounts and customer accounts. Calculates net income.'
      operationId: getIncomeStatement
      parameters:
        -
          name: from_date
          in: query
          description: 'Start date for the report period'
          required: true
          schema:
            type: string
            format: date
        -
          name: to_date
          in: query
          description: 'End date for the report period'
          required: true
          schema:
            type: string
            format: date
        -
          name: layer
          in: query
          description: 'Transaction layer to report on'
          required: false
          schema:
            type: string
            default: settled
            enum:
              - settled
              - pending
              - encumbrance
      responses:
        200:
          description: 'Income statement report'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          period:
                            properties:
                              from_date:
                                type: string
                                format: date
                              to_date:
                                type: string
                                format: date
                            type: object
                          layer:
                            type: string
                          revenue:
                            type: object
                          expenses:
                            type: object
                          net_income:
                            type: string
                            example: '5000.00'
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/strings/{code}/accounts':
    get:
      tags:
        - Strings
      summary: 'List accounts across all member ledgers of a string'
      description: 'List every account across all member ledgers of the string (cursor-paginated).'
      operationId: listStringAccounts
      parameters:
        -
          name: code
          in: path
          description: 'String code (unique within the tenant)'
          required: true
          schema:
            type: string
            example: treasury-fx
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/StringAccountRow'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: "String not found or the key's ledger is not a member"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/strings/{code}/accounts/{accountCode}/balance':
    get:
      tags:
        - Strings
      summary: 'Unified per-ledger balance for an account code across a string'
      description: 'Returns one balance line PER MEMBER LEDGER (never collapsed by currency). Member ledgers that have not materialized the account get a synthesized zero line flagged `materialized: false`. Pass `?include=total_in_base_currency` (#103) to also receive an indicative sum of every member line converted to the base currency at the current FX rate; the base defaults to the configured reporting currency and can be overridden with `?base_currency=`.'
      operationId: getStringAccountBalance
      parameters:
        -
          name: code
          in: path
          description: 'String code (unique within the tenant)'
          required: true
          schema:
            type: string
            example: treasury-fx
        -
          name: accountCode
          in: path
          description: 'Account code (logically present across the whole string)'
          required: true
          schema:
            type: string
            maxLength: 36
            example: '1000'
        -
          name: include
          in: query
          description: 'Comma-separated opt-in extras. Currently: `total_in_base_currency`.'
          required: false
          schema:
            type: string
            example: total_in_base_currency
        -
          name: base_currency
          in: query
          description: 'Base currency for `total_in_base_currency` (defaults to the configured reporting currency).'
          required: false
          schema:
            type: string
            example: USD
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/StringUnifiedBalance'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: "String/account not found or the key's ledger is not a member"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Invalid account code'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/strings/{stringCode}/transfer':
    post:
      tags:
        - Strings
      summary: 'Post an atomic same-currency transfer between two member ledgers of a string'
      description: 'Moves an equal amount of a customer account from the source member ledger to the target member ledger (same currency, no rate). BOTH legs post atomically in one database transaction. Requires an api key whose ledger is a member of the string and which carries the `strings:participate:{stringCode}` capability. Supply a first-class idempotency key via the `Idempotency-Key` header (or an `idempotency_key` body field); replaying it returns the original result without double-posting.'
      operationId: postStringTransfer
      parameters:
        -
          name: stringCode
          in: path
          description: 'String code (unique within the tenant).'
          required: true
          schema:
            type: string
            example: treasury-fx
        -
          name: Idempotency-Key
          in: header
          description: 'First-class idempotency key (canonical). Falls back to the body `idempotency_key` when absent.'
          required: false
          schema:
            type: string
            maxLength: 255
            example: transfer-2026-0001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - source_ledger_code
                - target_ledger_code
                - account_code
                - amount
                - reference
              properties:
                source_ledger_code:
                  description: 'Code of the member ledger value leaves.'
                  type: string
                  example: usd-main
                target_ledger_code:
                  description: 'Code of the member ledger value arrives in (must differ from the source).'
                  type: string
                  example: usd-ops
                account_code:
                  description: 'The logical customer account, present across the string.'
                  type: string
                  maxLength: 36
                  example: '1000'
                amount:
                  description: 'Amount to move, in the shared currency (major units).'
                  type: string
                  example: '100.00'
                reference:
                  description: 'The per-ledger transaction reference stamped on both legs.'
                  type: string
                  maxLength: 100
                  example: XFER-2026-0001
                effective_date:
                  description: 'Posting effective date (Y-m-d). Defaults to today when omitted.'
                  type: string
                  format: date
                  example: '2026-06-30'
                  nullable: true
                description:
                  description: 'Optional human description recorded on both legs.'
                  type: string
                  maxLength: 255
                  example: 'Treasury sweep'
                  nullable: true
                attributes:
                  description: 'Optional searchable business dimensions — a flat map of `key` => `value` string pairs (e.g. `{"customer_id": "C-123", "loan_id": "L-42"}`) stamped on BOTH legs (each leg is independently searchable). Keys must be lowercase snake_case (max 64 chars) and not shadow a reserved field; values are strings (max 128 chars). Subject to per-transaction (default 20) and per-tenant distinct-key caps, and, when the tenant runs strict mode, must be declared in the attribute catalog first — a violation returns 422 with an `ATTRIBUTE_*` code synchronously at the gate. Written atomically with both legs and immutable thereafter.'
                  type: object
                  example:
                    customer_id: C-123
                    loan_id: L-42
                  nullable: true
                  additionalProperties:
                    type: string
                    maxLength: 128
                idempotency_key:
                  description: 'Body fallback for the idempotency key when the Idempotency-Key header is not sent.'
                  type: string
                  maxLength: 255
                  example: transfer-2026-0001
                  nullable: true
              type: object
      responses:
        201:
          description: 'Transfer posted (both legs committed)'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/StringOperationResult'
                    type: object
        200:
          description: 'Idempotent replay: the original result is returned'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/StringOperationResult'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: 'Forbidden (STRING_KEY_NOT_IN_STRING / STRING_KEY_MISSING_CAPABILITY / STRING_FOREIGN_LEDGER / STRING_OP_NOT_ALLOWED)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Conflict (STRING_PERIOD_LOCKED / STRING_INACTIVE / STRING_NOT_FOUND)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error (STRING_CURRENCY_MISMATCH / STRING_OPERATION_INVALID)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/strings/{stringCode}/convert':
    post:
      tags:
        - Strings
      summary: 'Post an atomic different-currency conversion between two member ledgers of a string'
      description: 'Moves value from a customer account in the source member ledger to the target member ledger in a DIFFERENT currency. Both `source_amount` and `target_amount` are authoritative; `rate` is recorded for audit only. BOTH legs post atomically. Requires an api key whose ledger is a member of the string and which carries the `strings:participate:{stringCode}` capability. Supply a first-class idempotency key via the `Idempotency-Key` header (or an `idempotency_key` body field); replaying it returns the original result without double-posting.'
      operationId: postStringConvert
      parameters:
        -
          name: stringCode
          in: path
          description: 'String code (unique within the tenant).'
          required: true
          schema:
            type: string
            example: treasury-fx
        -
          name: Idempotency-Key
          in: header
          description: 'First-class idempotency key (canonical). Falls back to the body `idempotency_key` when absent.'
          required: false
          schema:
            type: string
            maxLength: 255
            example: convert-2026-0001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - source_ledger_code
                - target_ledger_code
                - account_code
                - source_amount
                - target_amount
                - reference
              properties:
                source_ledger_code:
                  description: 'Code of the member ledger value leaves.'
                  type: string
                  example: usd-main
                target_ledger_code:
                  description: 'Code of the member ledger value arrives in (must differ from the source and use a different currency).'
                  type: string
                  example: eur-main
                account_code:
                  description: 'The logical customer account, present across the string.'
                  type: string
                  maxLength: 36
                  example: '1000'
                source_amount:
                  description: 'Authoritative amount leaving the source account, in the source currency (major units).'
                  type: string
                  example: '100.00'
                target_amount:
                  description: 'Authoritative amount arriving in the target account, in the target currency (major units).'
                  type: string
                  example: '92.00'
                rate:
                  description: 'The conversion rate, recorded for audit only (never used to recompute either amount). OPTIONAL: when omitted, a live rate is resolved from the FX rate service and snapshotted before posting; an explicit rate stays authoritative.'
                  type: string
                  example: '0.92'
                  nullable: true
                reference:
                  description: 'The per-ledger transaction reference stamped on both legs.'
                  type: string
                  maxLength: 100
                  example: FX-2026-0001
                effective_date:
                  description: 'Posting effective date (Y-m-d). Defaults to today when omitted.'
                  type: string
                  format: date
                  example: '2026-06-30'
                  nullable: true
                description:
                  description: 'Optional human description recorded on both legs.'
                  type: string
                  maxLength: 255
                  example: 'USD→EUR conversion'
                  nullable: true
                attributes:
                  description: 'Optional searchable business dimensions — a flat map of `key` => `value` string pairs (e.g. `{"customer_id": "C-123", "loan_id": "L-42"}`) stamped on BOTH legs (each leg is independently searchable). Keys must be lowercase snake_case (max 64 chars) and not shadow a reserved field; values are strings (max 128 chars). Subject to per-transaction (default 20) and per-tenant distinct-key caps, and, when the tenant runs strict mode, must be declared in the attribute catalog first — a violation returns 422 with an `ATTRIBUTE_*` code synchronously at the gate. Written atomically with both legs and immutable thereafter.'
                  type: object
                  example:
                    customer_id: C-123
                    loan_id: L-42
                  nullable: true
                  additionalProperties:
                    type: string
                    maxLength: 128
                idempotency_key:
                  description: 'Body fallback for the idempotency key when the Idempotency-Key header is not sent.'
                  type: string
                  maxLength: 255
                  example: convert-2026-0001
                  nullable: true
              type: object
      responses:
        201:
          description: 'Conversion posted (both legs committed)'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/StringOperationResult'
                    type: object
        200:
          description: 'Idempotent replay: the original result is returned'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/StringOperationResult'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: 'Forbidden (STRING_KEY_NOT_IN_STRING / STRING_KEY_MISSING_CAPABILITY / STRING_FOREIGN_LEDGER / STRING_OP_NOT_ALLOWED)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Conflict (STRING_PERIOD_LOCKED / STRING_INACTIVE / STRING_NOT_FOUND)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error (STRING_CURRENCY_MISMATCH / STRING_OPERATION_INVALID)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/trancodes:
    get:
      tags:
        - 'Transaction Codes'
      summary: 'List transaction code templates'
      description: 'Display a listing of tranCodes for the specified ledger.'
      operationId: getTransactionCodes
      parameters:
        -
          name: status
          in: query
          description: 'Filter by status'
          required: false
          schema:
            type: string
            enum:
              - active
              - deprecated
        -
          name: search
          in: query
          description: 'Search in code, name, or description'
          required: false
          schema:
            type: string
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/TransactionCode'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - 'Transaction Codes'
      summary: 'Create a transaction code template'
      description: 'Store a newly created tranCode.'
      operationId: createTransactionCode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - code
                - name
                - entries_template
              properties:
                code:
                  description: 'Unique code for this template (immutable after creation; uppercase letters, numbers, hyphen, underscore)'
                  type: string
                  example: PAYMENT
                name:
                  type: string
                  example: 'Customer Payment'
                description:
                  type: string
                  nullable: true
                status:
                  type: string
                  default: active
                  enum:
                    - active
                    - deprecated
                params_schema:
                  oneOf:
                    -
                      $ref: '#/components/schemas/ParamsSchema'
                  nullable: true
                entries_template:
                  $ref: '#/components/schemas/EntriesTemplate'
                attributes_template:
                  oneOf:
                    -
                      $ref: '#/components/schemas/AttributesTemplate'
                  nullable: true
                validation_rules:
                  type: object
                  nullable: true
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        201:
          description: 'Transaction code created'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/TransactionCode'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/trancodes/{id}':
    get:
      tags:
        - 'Transaction Codes'
      summary: 'Get a specific transaction code template'
      description: 'Display the specified tranCode.'
      operationId: getTransactionCode
      parameters:
        -
          name: id
          in: path
          description: 'Transaction Code UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/TransactionCode'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction code not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    put:
      tags:
        - 'Transaction Codes'
      summary: 'Update a transaction code template (increments version)'
      description: "Update the specified tranCode.\nNote: Updates increment the version number for audit trail."
      operationId: updateTransactionCode
      parameters:
        -
          name: id
          in: path
          description: 'Transaction Code UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: '`code` is immutable after creation and is silently ignored if supplied here.'
              required:
                - name
                - entries_template
              properties:
                name:
                  type: string
                description:
                  type: string
                  nullable: true
                params_schema:
                  oneOf:
                    -
                      $ref: '#/components/schemas/ParamsSchema'
                  nullable: true
                entries_template:
                  $ref: '#/components/schemas/EntriesTemplate'
                attributes_template:
                  oneOf:
                    -
                      $ref: '#/components/schemas/AttributesTemplate'
                  nullable: true
                validation_rules:
                  type: object
                  nullable: true
                metadata:
                  type: object
                  nullable: true
              type: object
      responses:
        200:
          description: 'Transaction code updated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/TransactionCode'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction code not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/trancodes/{id}/deprecate':
    post:
      tags:
        - 'Transaction Codes'
      summary: 'Mark a transaction code as deprecated (one-way state transition)'
      description: 'Sets `status` to `deprecated`. There is no API path back to `active` — once deprecated, the transaction code remains in the system for historical reference but is no longer suitable for new transactions. The record itself is never deleted; existing transactions that referenced it stay valid.'
      operationId: deprecateTransactionCode
      parameters:
        -
          name: id
          in: path
          description: 'Transaction Code UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Transaction code deprecated'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/TransactionCode'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction code not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/trancodes/{id}/validate-params':
    post:
      tags:
        - 'Transaction Codes'
      summary: "Validate a parameter payload against a transaction code's params_schema"
      description: "Dry-run check of a `params` payload against the transaction code's `params_schema`. Returns 200 with `valid: true` when all required parameters are supplied and types match, or 422 with a per-field `errors` map otherwise. Useful before invoking the trancode to surface validation errors without creating a transaction."
      operationId: validateTransactionCodeParams
      parameters:
        -
          name: id
          in: path
          description: 'Transaction Code UUID'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              properties:
                params:
                  description: "Parameter payload to validate against the trancode's `params_schema`. Map of parameter name → value."
                  type: object
                  example:
                    amount: '100.00'
                    from_account: '1000'
                    to_account: '4000'
              type: object
      responses:
        200:
          description: 'Parameters are valid'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          valid:
                            type: boolean
                            example: true
                          message:
                            type: string
                            example: 'Parameters are valid'
                        type: object
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction code not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Parameter validation failed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/transactions:
    get:
      tags:
        - Transactions
      summary: 'List all transactions in a ledger'
      description: "Display a listing of the resource.\n\nUses cursor-based pagination for consistency. See docs/api/PAGINATION.md"
      operationId: getTransactions
      parameters:
        -
          name: layer
          in: query
          description: 'Filter by transaction layer'
          required: false
          schema:
            type: string
            enum:
              - settled
              - pending
              - encumbrance
        -
          name: status
          in: query
          description: 'Filter by transaction status'
          required: false
          schema:
            type: string
            enum:
              - pending
              - posted
              - void
              - failed
              - reversed
        -
          name: from_date
          in: query
          description: 'Filter transactions from this date'
          required: false
          schema:
            type: string
            format: date
        -
          name: to_date
          in: query
          description: 'Filter transactions to this date'
          required: false
          schema:
            type: string
            format: date
        -
          name: correlation_id
          in: query
          description: 'Filter by correlation ID'
          required: false
          schema:
            type: string
        -
          name: limit
          in: query
          description: 'Number of items per page (max 100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Transaction'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
    post:
      tags:
        - Transactions
      summary: 'Create a new transaction (direct entries or trancode invocation)'
      description: "Two posting modes are accepted on this endpoint and are mutually exclusive — exactly one must be used per request:\n\n**Mode 1 — Direct entries.** Caller supplies `entries[]` and the server stores them after balance + account-existence checks. Use this when each transaction is bespoke or when the caller already knows the exact debit/credit pairs to write.\n\n**Mode 2 — Transaction-code invocation.** Caller supplies `transaction_code` + `transaction_code_params`. The server looks up the named, active trancode in the current ledger, validates the params against its `params_schema`, runs the entries template through the expression engine (substitution + CEL-based Money grammar — `+ - * /` and the `round()` helper), and writes the resulting entries. Use this when the same transaction shape is replayed many times — define the template once via `POST /trancodes`, invoke by code thereafter. Aligns with the convention used by ledger systems where standardised transaction shapes are encoded as templates.\n\nSending both `entries` and `transaction_code` on the same request returns 422 — the server will not guess which mode the caller intended.\n\nTop-level fields (`effective_date`, `description`, `layer`, `journal_id`, `correlation_id`, `correlation_type`, `metadata`, `idempotency_key`) apply to both modes."
      operationId: createTransaction
      requestBody:
        required: true
        content:
          application/json:
            schema:
              description: 'Either `entries` (Mode 1) or `transaction_code` + `transaction_code_params` (Mode 2) must be supplied — never both.'
              required:
                - effective_date
                - description
                - idempotency_key
              properties:
                effective_date:
                  description: 'Transaction effective date'
                  type: string
                  format: date
                  example: '2025-01-15'
                description:
                  type: string
                  example: 'Payment received from customer'
                reference:
                  description: 'External reference number'
                  type: string
                  example: INV-001
                layer:
                  description: 'Case-insensitive on the request side; values like `Settled` or `SETTLED` are normalised to canonical lowercase before validation. Responses always render the canonical lowercase form.'
                  type: string
                  default: settled
                  enum:
                    - settled
                    - pending
                    - encumbrance
                journal_id:
                  type: string
                  format: uuid
                  nullable: true
                correlation_id:
                  description: "Group related transactions. If omitted but correlation_source_id is given, defaults to the source transaction's group handle (its correlation_id, else its id)."
                  type: string
                  nullable: true
                correlation_type:
                  description: 'Correlation classifier (e.g. invoice, refund, reschedule). Paired with correlation_source_id — supply both or neither.'
                  type: string
                  maxLength: 50
                  nullable: true
                correlation_source_id:
                  description: 'Optional predecessor transaction (same ledger) to link to. Creates a Correlation pair row (source = this id, target = the new transaction) classified by correlation_type — e.g. a reschedule replacement pointing at its voided original.'
                  type: string
                  format: uuid
                  nullable: true
                idempotency_key:
                  description: 'Unique key to prevent duplicate transactions'
                  type: string
                  example: unique-key-123
                metadata:
                  type: object
                  nullable: true
                attributes:
                  description: 'Optional searchable business dimensions — a flat map of `key` => `value` string pairs (e.g. `{"customer_id": "C-123", "loan_id": "L-42"}`). Applies to both posting modes. Keys must be lowercase snake_case (max 64 chars) and not shadow a reserved field; values are strings (max 128 chars). Subject to per-transaction (default 20) and per-tenant distinct-key caps, and, when the tenant runs strict mode, must be declared in the attribute catalog first — a violation returns 422 with an `ATTRIBUTE_*` code. Written atomically with the transaction and immutable thereafter.'
                  type: object
                  example:
                    customer_id: C-123
                    loan_id: L-42
                  nullable: true
                  additionalProperties:
                    type: string
                    maxLength: 128
                entries:
                  description: 'Mode 1 only. Double-entry transaction entries (must balance after server-side resolution). Mutually exclusive with `transaction_code`.'
                  type: array
                  items:
                    required:
                      - type
                      - amount
                    properties:
                      account_code:
                        description: 'Account code (alternative to account_id)'
                        type: string
                        example: '1000'
                      account_id:
                        description: 'Account UUID (alternative to account_code)'
                        type: string
                        format: uuid
                      type:
                        type: string
                        enum:
                          - debit
                          - credit
                        example: debit
                      amount:
                        description: 'Amount in major units (e.g., dollars)'
                        type: string
                        example: '100.00'
                      description:
                        type: string
                        nullable: true
                      metadata:
                        type: object
                        nullable: true
                    type: object
                  example:
                    -
                      account_code: '1000'
                      type: debit
                      amount: '100.00'
                    -
                      account_code: '4000'
                      type: credit
                      amount: '100.00'
                transaction_code:
                  description: 'Mode 2 only. Code of an active trancode in this ledger. Must be paired with `transaction_code_params`. Mutually exclusive with `entries`.'
                  type: string
                  maxLength: 50
                  example: BOOK_TRANSFER
                transaction_code_params:
                  description: "Mode 2 only. Parameter map keyed by parameter name as declared in the trancode's `params_schema`. Values are substituted into the template (account selectors and amount expressions) at materialisation time."
                  type: object
                  example:
                    amount: '100.00'
                    from_account: '1000'
                    to_account: '4000'
              type: object
      responses:
        202:
          description: 'Transaction accepted and processing'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          id:
                            type: string
                            format: uuid
                          status:
                            type: string
                            example: pending
                          idempotency_key:
                            type: string
                          correlation_id:
                            type: string
                          message:
                            type: string
                        type: object
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Idempotency key conflict'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        503:
          description: 'Idempotency service unavailable (code SERVICE_UNAVAILABLE) — fail-closed with a Retry-After header; nothing is written'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/transactions/correlation:
    get:
      tags:
        - Transactions
      summary: 'List transactions sharing a correlation ID (chronological)'
      description: "List transactions sharing a correlation ID, in posting order.\n\nThe correlation id is taken as a **query parameter** (`?correlation_id=`),\nnot a path segment, so it can round-trip any value the write API accepts\n(`string|max:50`) — including ids containing '/', '%', etc. that a path\nsegment cannot carry.\n\nReturns the full correlation trail for that id within the authenticated\nledger. Deliberately ordered chronologically by posting time (created_at\nasc, id asc) — a correlation trail answers \"what happened, and in what\norder\" — which is distinct from the transaction list endpoint's\nfinancial-chronology ordering (effective_date desc). The UUIDv7 `id` is\na stable, time-ordered tiebreaker.\n\nLedger-scoped via $ledger->transactions(): a key issued for ledger A\nnever surfaces ledger B's rows even if both used the same correlation\nid. An unknown or foreign correlation id yields an empty paginator\n(200 with empty data) rather than a 404 — the endpoint is not an\nexistence oracle.\n\nUses cursor-based pagination for consistency. See docs/api/PAGINATION.md"
      operationId: getTransactionsByCorrelation
      parameters:
        -
          name: correlation_id
          in: query
          description: 'Correlation ID to group transactions by'
          required: true
          schema:
            type: string
            maxLength: 50
        -
          name: limit
          in: query
          description: 'Number of items per page (1-100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
            minimum: 1
        -
          name: cursor
          in: query
          description: 'Cursor for pagination'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation (empty data when no transactions share the id)'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Transaction'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error (missing or too-long correlation_id)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/transactions/by-attribute:
    get:
      tags:
        - Transactions
      summary: 'Search transactions by a searchable business dimension (tenant-scoped, cross-ledger)'
      description: 'Return the transactions carrying a given `key`=`value` dimension (e.g. `customer_id`=`C-123`), newest first. Tenant-scoped and cross-ledger by default; the tenant is derived server-side from the API key and is never taken from the query string. A value that exists only in another tenant returns an empty paginator (never a 404). Optional `ledger_id` narrows to one ledger; optional `from`/`to` bound the `transaction_created_at` window.'
      operationId: searchTransactionsByAttribute
      parameters:
        -
          name: key
          in: query
          description: 'Attribute key (dimension) to search on, e.g. customer_id'
          required: true
          schema:
            type: string
            maxLength: 64
        -
          name: value
          in: query
          description: 'Exact attribute value to match, e.g. C-123'
          required: true
          schema:
            type: string
            maxLength: 128
        -
          name: ledger_id
          in: query
          description: 'Optional — narrow the search to a single ledger within the tenant'
          required: false
          schema:
            type: string
            format: uuid
        -
          name: from
          in: query
          description: 'Optional inclusive lower bound on transaction_created_at (enables partition pruning). Format `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` — NO timezone (the column is timestamp without time zone).'
          required: false
          schema:
            type: string
            pattern: '^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$'
            example: '2026-07-01 00:00:00'
        -
          name: to
          in: query
          description: 'Optional inclusive upper bound on transaction_created_at (enables partition pruning). Format `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` — NO timezone (the column is timestamp without time zone).'
          required: false
          schema:
            type: string
            pattern: '^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$'
            example: '2026-07-31 23:59:59'
        -
          name: limit
          in: query
          description: 'Number of items per page (1-100)'
          required: false
          schema:
            type: integer
            default: 25
            maximum: 100
            minimum: 1
        -
          name: cursor
          in: query
          description: 'Opaque keyset cursor returned by this endpoint. A malformed or foreign cursor is rejected as 422.'
          required: false
          schema:
            type: string
      responses:
        200:
          description: 'Successful operation (empty data when no transaction in this tenant carries the dimension)'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Transaction'
                      meta:
                        $ref: '#/components/schemas/PaginationMeta'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: 'Forbidden (no tenant context)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Validation error (missing/too-long key or value, bad ledger_id/date)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  /api/v1/transactions/batch:
    post:
      tags:
        - Transactions
      summary: 'Create multiple transactions in a batch'
      description: 'Create multiple transactions in a single API request. Each transaction is processed independently, supporting partial success. Maximum 100 transactions per batch (configurable). Searchable attributes are NOT supported on this endpoint: a batch item carrying `attributes` is rejected with 422 (attributes are immutable and write-once, so silently dropping them would be unrecoverable). Create attributed transactions via POST /api/v1/transactions.'
      operationId: createTransactionBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - transactions
              properties:
                transactions:
                  description: 'Array of transactions to create (max 100)'
                  type: array
                  items:
                    required:
                      - idempotency_key
                      - effective_date
                      - description
                      - entries
                    properties:
                      idempotency_key:
                        description: 'Unique key for idempotency (required per transaction)'
                        type: string
                        example: tx-001
                      reference:
                        description: 'External reference number'
                        type: string
                        example: INV-001
                      description:
                        type: string
                        example: 'Invoice payment'
                      effective_date:
                        type: string
                        format: date
                        example: '2026-01-15'
                      layer:
                        description: 'Case-insensitive on the request side; values like `Settled` or `SETTLED` are normalised to canonical lowercase before validation. Responses always render the canonical lowercase form.'
                        type: string
                        default: settled
                        enum:
                          - settled
                          - pending
                          - encumbrance
                      journal_id:
                        type: string
                        format: uuid
                        nullable: true
                      correlation_id:
                        type: string
                        nullable: true
                      metadata:
                        type: object
                        nullable: true
                      entries:
                        type: array
                        items:
                          required:
                            - type
                            - amount
                          properties:
                            account_code:
                              type: string
                              example: '1000'
                            account_id:
                              type: string
                              format: uuid
                            type:
                              type: string
                              enum:
                                - debit
                                - credit
                            amount:
                              type: string
                              example: '100.00'
                            description:
                              type: string
                              nullable: true
                            metadata:
                              type: object
                              nullable: true
                          type: object
                    type: object
              type: object
            example:
              transactions:
                -
                  idempotency_key: tx-001
                  reference: INV-001
                  description: 'Invoice payment'
                  effective_date: '2026-01-15'
                  entries:
                    -
                      account_code: '1000'
                      type: debit
                      amount: '100.00'
                    -
                      account_code: '4000'
                      type: credit
                      amount: '100.00'
                -
                  idempotency_key: tx-002
                  reference: INV-002
                  description: 'Second invoice payment'
                  effective_date: '2026-01-15'
                  entries:
                    -
                      account_code: '1000'
                      type: debit
                      amount: '200.00'
                    -
                      account_code: '4000'
                      type: credit
                      amount: '200.00'
      responses:
        202:
          description: 'Batch processed (may include partial success)'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        properties:
                          results:
                            type: array
                            items:
                              properties:
                                idempotency_key:
                                  type: string
                                status:
                                  type: string
                                  enum:
                                    - accepted
                                    - rejected
                                id:
                                  type: string
                                  format: uuid
                                  nullable: true
                                correlation_id:
                                  type: string
                                  nullable: true
                                error:
                                  type: string
                                  nullable: true
                                error_code:
                                  type: string
                                  nullable: true
                              type: object
                          summary:
                            properties:
                              total:
                                type: integer
                              accepted:
                                type: integer
                              rejected:
                                type: integer
                            type: object
                        type: object
                    type: object
        400:
          description: 'Validation error (batch format invalid)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        503:
          description: 'Idempotency service unavailable (code SERVICE_UNAVAILABLE) — the whole batch is rejected fail-closed with a Retry-After header; nothing is written'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/transactions/{transactionId}':
    get:
      tags:
        - Transactions
      summary: 'Get a specific transaction'
      description: 'Display the specified resource.'
      operationId: getTransaction
      parameters:
        -
          name: transactionId
          in: path
          description: 'Transaction UUID'
          required: true
          schema:
            type: string
            format: uuid
      responses:
        200:
          description: 'Successful operation'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Transaction'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/transactions/{transactionId}/reverse':
    post:
      tags:
        - Transactions
      summary: 'Reverse a posted transaction'
      description: 'Reverse a transaction'
      operationId: reverseTransaction
      parameters:
        -
          name: transactionId
          in: path
          description: 'Transaction UUID to reverse'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              required:
                - reason
                - date
              properties:
                reason:
                  description: 'Reason for reversal'
                  type: string
                  example: 'Customer refund'
                date:
                  description: 'Effective date of reversal'
                  type: string
                  format: date
                  example: '2025-01-15'
                reference:
                  type: string
                  example: REF-001
                  nullable: true
                metadata:
                  type: object
                  nullable: true
                idempotency_key:
                  type: string
                  nullable: true
              type: object
      responses:
        201:
          description: 'Reversal transaction created'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Transaction'
                    type: object
        400:
          description: 'Validation error'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Idempotency conflict — request with this key in progress (idempotency_key_in_use) or key already used for a different operation (idempotency_key_reused)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        422:
          description: 'Transaction cannot be reversed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        503:
          description: 'Idempotency service unavailable (code SERVICE_UNAVAILABLE) — fail-closed with a Retry-After header; nothing is written'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
  '/api/v1/transactions/{transactionId}/void':
    post:
      tags:
        - Transactions
      summary: 'Void a posted transaction'
      description: "Void a posted transaction.\n\nFlips status to VOID and recalculates balances so the transaction's\neffect disappears, without deleting rows or touching entries (Article\n1.1: lifecycle metadata is mutable, financial content is not). This is\nthe sanctioned \"remove from schedule\" mechanism for encumbrance-layer\nschedules (e.g. reschedules) — see docs/api/rest/transactions.md."
      operationId: voidTransaction
      parameters:
        -
          name: transactionId
          in: path
          description: 'Transaction UUID to void'
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              properties:
                reason:
                  description: 'Optional reason recorded on the transaction and in the activity log.'
                  type: string
                  maxLength: 255
                  example: rescheduled
                  nullable: true
              type: object
      responses:
        200:
          description: 'Transaction voided'
          content:
            application/json:
              schema:
                allOf:
                  -
                    $ref: '#/components/schemas/SuccessEnvelope'
                  -
                    properties:
                      data:
                        $ref: '#/components/schemas/Transaction'
                    type: object
        401:
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        403:
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        404:
          description: 'Transaction not found'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        409:
          description: 'Not voidable (code TRANSACTION_NOT_VOIDABLE) or effective date in a locked period (code PERIOD_LOCKED)'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      security:
        -
          apiKey: []
components:
  schemas:
    StringAccountRow:
      title: 'String Account Row'
      description: 'One account in one member ledger of a string, identifying its owning ledger and account identity.'
      required:
        - id
        - ledger_id
        - code
        - name
        - type
        - normal_balance
      properties:
        id:
          description: 'Account id.'
          type: string
          format: uuid
        ledger_id:
          description: 'Id of the owning member ledger.'
          type: string
          format: uuid
        ledger_code:
          description: 'Code of the owning member ledger'
          type: string
          example: gbp-main
        currency:
          description: 'Currency of the owning member ledger'
          type: string
          example: GBP
        code:
          type: string
          example: '1000'
        name:
          type: string
          example: Cash
        type:
          type: string
          enum:
            - asset
            - liability
            - equity
            - revenue
            - expense
          example: asset
        category:
          type: string
          enum:
            - system
            - customer
          example: customer
        normal_balance:
          type: string
          enum:
            - debit
            - credit
          example: debit
        is_active:
          type: boolean
          example: true
        materialized:
          description: 'True when this row is a materialized copy sourced from a sibling member (its string_source_id is set).'
          type: boolean
          example: false
      type: object
    Account:
      title: Account
      description: 'A chart of accounts entry for tracking financial transactions'
      required:
        - id
        - code
        - name
        - type
        - category
        - normal_balance
      properties:
        id:
          type: string
          format: uuid
        ledger_id:
          type: string
          format: uuid
        parent_id:
          description: 'Parent account for hierarchical structure'
          type: string
          format: uuid
          nullable: true
        code:
          description: 'Account code (unique within ledger)'
          type: string
          example: '1000'
        name:
          type: string
          example: Cash
        type:
          type: string
          enum:
            - asset
            - liability
            - equity
            - revenue
            - expense
          example: asset
        category:
          description: 'Account category — system (internal/GL accounts) or customer (end-user balance accounts)'
          type: string
          enum:
            - system
            - customer
          example: system
        normal_balance:
          description: 'Normal balance side'
          type: string
          enum:
            - debit
            - credit
          example: debit
        balance:
          description: 'Current balance in major units'
          type: string
          example: '1000.00'
        description:
          type: string
          nullable: true
        is_active:
          type: boolean
          example: true
        is_system:
          description: 'System accounts cannot be deleted'
          type: boolean
          example: false
        metadata:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    AccountSet:
      title: 'Account Set'
      description: 'A logical grouping of accounts for reporting purposes'
      required:
        - id
        - code
        - name
      properties:
        id:
          type: string
          format: uuid
        ledger_id:
          type: string
          format: uuid
        code:
          description: 'Unique code within ledger'
          type: string
          example: OPERATING_EXPENSES
        name:
          type: string
          example: 'Operating Expenses'
        description:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    AttributeDefinition:
      title: 'Attribute Definition'
      description: 'A catalogued searchable transaction dimension ("key") for a tenant. Auto-populated on first use or declared explicitly for strict mode.'
      required:
        - id
        - key
        - indexed
        - usage_count
      properties:
        id:
          type: string
          format: uuid
        key:
          description: 'The governed dimension name (lowercase snake_case, unique within the tenant).'
          type: string
          maxLength: 64
          example: customer_id
        label:
          description: 'Optional human-friendly display name.'
          type: string
          example: 'Customer ID'
          nullable: true
        indexed:
          description: 'Reserved forward-compatibility metadata; has NO behavioural effect in v1 — a single composite index serves every key, so search works identically for indexed=false dimensions.'
          type: boolean
          example: true
        usage_count:
          description: 'Number of times the key has been stamped on a transaction.'
          type: integer
          format: int64
          example: 1200
        first_seen_at:
          description: 'When the key was first used (null for a declared-but-unused key).'
          type: string
          format: date-time
          nullable: true
        last_seen_at:
          description: 'When the key was most recently used.'
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    Entry:
      title: Entry
      description: 'A single entry in a double-entry transaction'
      required:
        - id
        - transaction_id
        - account_id
        - type
        - amount
      properties:
        id:
          type: string
          format: uuid
        account_id:
          type: string
          format: uuid
        account_code:
          type: string
          example: '1000'
        account_name:
          type: string
          example: Cash
          nullable: true
        amount:
          description: 'Amount in major units'
          type: string
          example: '100.00'
        type:
          type: string
          enum:
            - debit
            - credit
          example: debit
        description:
          type: string
          nullable: true
        layer:
          type: string
          enum:
            - settled
            - pending
            - encumbrance
          example: settled
      type: object
    ExchangeRate:
      title: 'Exchange Rate'
      description: 'A stored FX exchange-rate snapshot for a currency pair. `rate` and `inverse_rate` are decimal ratios (strings), never minor-unit money.'
      required:
        - id
        - base_currency
        - quote_currency
        - rate
        - inverse_rate
        - source
        - effective_at
      properties:
        id:
          description: "A stored FX exchange-rate snapshot for a currency pair within a tenant (#103).\n\nRows are either caller-supplied `manual` overrides or provider-fetched live rates\n(e.g. `ecb`); both are stored per-tenant. `rate` / `inverse_rate` are DECIMAL\nRATIOS (NUMERIC(40,18)), never minor-unit money — they are cast to `string` so a\nhigh-precision rate is NEVER coerced through a lossy float.\n\nIt carries the {@see HasTenancy} global scope like every other tenant model, but\nthe FX read/write paths ({@see \\App\\Services\\Fx\\ManualRateProvider},\n{@see \\App\\Services\\Fx\\ExchangeRateService}) additionally pin an explicit\n`tenant_id` predicate so they are correct regardless of ambient isolation state\n(charter #166 security rule)."
          type: string
          format: uuid
        base_currency:
          description: 'Base currency (1 unit of this).'
          type: string
          example: EUR
        quote_currency:
          description: 'Quote currency (how much 1 base is worth).'
          type: string
          example: USD
        rate:
          description: '1 base_currency = rate quote_currency (decimal ratio).'
          type: string
          example: '1.081000000000000000'
        inverse_rate:
          description: 'Reciprocal of rate (1 quote_currency in base_currency).'
          type: string
          example: '0.925069380203515263'
        source:
          description: 'Provenance: manual | ecb | openexchangerates | …'
          type: string
          example: manual
        effective_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
      type: object
    Journal:
      title: Journal
      description: 'A journal for organizing related transactions'
      required:
        - id
        - code
        - name
        - status
      properties:
        id:
          type: string
          format: uuid
        ledger_id:
          type: string
          format: uuid
        code:
          description: 'Unique code within ledger'
          type: string
          example: SALES
        name:
          type: string
          example: 'Sales Journal'
        description:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - active
            - closed
          example: active
        metadata:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    Ledger:
      title: Ledger
      description: 'A financial ledger containing chart of accounts and transactions'
      required:
        - id
        - code
        - name
        - currency
        - status
      properties:
        id:
          type: string
          format: uuid
          example: 0191234-5678-90ab-cdef-1234567890ab
        code:
          description: 'Unique ledger code'
          type: string
          example: main-ledger
        name:
          type: string
          example: 'Main Ledger'
        description:
          type: string
          example: 'Primary accounting ledger'
          nullable: true
        currency:
          description: 'ISO currency code'
          type: string
          example: USD
        storage_decimals:
          description: 'Storage precision (decimals)'
          type: integer
          example: 2
        display_decimals:
          description: 'Display precision'
          type: integer
          example: 2
        rounding_mode:
          type: string
          enum:
            - half_up
            - half_down
            - half_even
            - half_ceiling
            - half_floor
          example: half_up
        timezone:
          type: string
          example: America/New_York
        fiscal_year_start_month:
          description: 'Fiscal year start month (1-12)'
          type: integer
          example: 4
        fiscal_year_start_day:
          description: 'Fiscal year start day (1-31)'
          type: integer
          example: 6
        is_active:
          type: boolean
          example: true
        status:
          type: string
          enum:
            - active
            - inactive
          example: active
        settings:
          type: object
          nullable: true
        metadata:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
          example: '2025-10-16T12:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2025-10-16T12:00:00Z'
      type: object
    PaginationMeta:
      title: 'Pagination Metadata'
      description: 'Cursor-based pagination metadata as emitted by PaginatedResponse. Wraps the actual cursor fields under a `pagination` key inside the envelope `meta`.'
      properties:
        pagination:
          properties:
            limit:
              description: 'Page size requested.'
              type: integer
              example: 25
            has_more:
              description: 'True if another page is available.'
              type: boolean
              example: true
            next_cursor:
              type: string
              example: eyJpZCI6IjAxOWRjZjg3LTUwN2ItNzE5OS05ZTE1LWZhY2YxNjVlNzRjMSJ9
              nullable: true
            previous_cursor:
              type: string
              nullable: true
          type: object
      type: object
    LedgerString:
      title: 'Ledger String'
      description: 'A string links multiple single-currency ledgers into one logical, cross-currency group.'
      required:
        - id
        - code
        - name
        - is_active
      properties:
        id:
          type: string
          format: uuid
        code:
          description: 'String code (unique within the tenant)'
          type: string
          maxLength: 50
          example: treasury-fx
        name:
          type: string
          example: 'Treasury FX group'
        description:
          type: string
          example: 'Cross-currency treasury ledgers'
          nullable: true
        is_active:
          type: boolean
          example: true
        ledgers:
          description: 'Member ledgers (only present on the show endpoint).'
          type: array
          items:
            properties:
              id:
                type: string
                format: uuid
              code:
                type: string
                example: gbp-main
              name:
                type: string
                example: 'GBP Main'
              currency:
                type: string
                example: GBP
            type: object
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    Transaction:
      title: Transaction
      description: 'A double-entry accounting transaction'
      required:
        - id
        - ledger_id
        - description
        - effective_date
        - status
      properties:
        id:
          type: string
          format: uuid
        ledger_id:
          type: string
          format: uuid
        journal_id:
          type: string
          format: uuid
          nullable: true
        reference:
          type: string
          example: INV-001
          nullable: true
        description:
          type: string
          example: 'Payment received'
        effective_date:
          type: string
          format: date-time
        date:
          description: 'Alias for effective_date'
          type: string
          format: date-time
        layer:
          type: string
          enum:
            - settled
            - pending
            - encumbrance
          example: settled
        status:
          type: string
          enum:
            - pending
            - posted
            - void
            - failed
            - reversed
          example: posted
        total_amount:
          description: 'Total transaction amount in major units'
          type: string
          example: '100.00'
        entry_count:
          type: integer
          example: 2
        hash:
          description: 'Transaction hash for integrity'
          type: string
          nullable: true
        previous_hash:
          type: string
          nullable: true
        correlation_id:
          description: 'Group related transactions'
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        original_transaction_id:
          type: string
          format: uuid
          nullable: true
        reversal_reason:
          type: string
          nullable: true
        entries:
          type: array
          items:
            $ref: '#/components/schemas/Entry'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    EntryTemplate:
      title: 'Entry Template'
      description: 'A single entry in an entries_template. The `account` and `amount` fields each accept either a literal value or a parameter reference / expression that resolves at runtime when the trancode is materialised into a transaction.'
      required:
        - account
        - type
        - amount
      properties:
        account:
          description: 'Account **code** selector (not a UUID). May be a fixed account code (e.g. "1000") or a parameter reference (e.g. "{params.from_account}") whose resolved value must itself be an account code. Parameter references must use the `{params.NAME}` form — bare `{NAME}` is reserved for system-injected variables and is rejected by the expression engine. Resolved at trancode-execution time against the supplied params and used as the `account_code` of the generated transaction entry.'
          type: string
          example: '{params.from_account}'
        type:
          type: string
          enum:
            - debit
            - credit
        amount:
          description: 'Amount expression. May be a decimal string ("100.00"), a parameter reference ("{params.amount}"), or a CEL expression wrapped in braces ("{params.amount * 1.05}", "{round(params.amount * 0.025)}"). The expression dialect is [Google CEL](https://github.com/google/cel-spec) — Money values support `+ - * /` and the `round()` helper.'
          type: string
          example: '{params.amount * 1.05}'
        description:
          description: 'Optional per-entry description.'
          type: string
          nullable: true
      type: object
    EntriesTemplate:
      title: 'Entries Template'
      description: 'Double-entry template stored on a transaction code. At least 2 entries are required; once parameters are substituted, debits and credits must balance.'
      required:
        - entries
      properties:
        entries:
          type: array
          items:
            $ref: '#/components/schemas/EntryTemplate'
          minItems: 2
      type: object
      example:
        entries:
          -
            account: '{params.from_account}'
            type: credit
            amount: '{params.amount}'
          -
            account: '{params.to_account}'
            type: debit
            amount: '{params.amount}'
    AttributesTemplate:
      title: 'Attributes Template'
      description: 'Optional map of searchable business dimensions to auto-stamp on every transaction this code produces (epic #212). A flat `{ "<key>": "<value-expression>" }` object: each **key** is a lowercase snake_case dimension name (subject to the tenant''s attribute guard rails and strict mode, validated at create/update time), and each **value** is a literal (`"web"`), a parameter reference (`"{params.customer_id}"`), or a `{<CEL expression>}` resolved on a DEDICATED attribute runtime — it shares the `entries_template` engine''s complexity/length guards and Money grammar, but additionally allows string results and string `+` concatenation (e.g. `{params.region + "_gold"}`) while deliberately withholding CEL''s regex/string-function surface. Expressions must resolve to a string, integer, or Money value (raw floats and non-scalars are rejected). Resolved values are stamped atomically with the transaction; an explicit invoke-time `attributes` value for the same key overrides the template-derived one.'
      type: object
      example:
        channel: web
        customer_id: '{params.customer_id}'
      additionalProperties:
        type: string
    ParamsSchema:
      title: 'TranCode Parameter Schema'
      description: 'JSON-Schema-style declaration of the parameters this trancode accepts when generating a transaction.'
      properties:
        type:
          type: string
          enum:
            - object
          example: object
        required:
          description: 'Names of parameters that the caller must provide when invoking this trancode.'
          type: array
          items:
            type: string
          example:
            - amount
        properties:
          description: 'Map of parameter name → JSON-Schema fragment describing the parameter shape. Parameters that feed an `account` slot must resolve to an account **code** string (not a UUID), to match the trancode-generated transaction entry shape.'
          type: object
          example:
            amount:
              type: string
              description: 'Payment amount in major units'
            from_account:
              type: string
              description: 'Source account code'
            to_account:
              type: string
              description: 'Destination account code'
      type: object
    TransactionCode:
      title: 'Transaction Code'
      description: 'A reusable template for creating transactions with parameters'
      required:
        - id
        - code
        - name
        - entries_template
      properties:
        id:
          type: string
          format: uuid
        ledger_id:
          type: string
          format: uuid
        code:
          description: 'Unique code for this template (immutable after creation)'
          type: string
          example: PAYMENT
        name:
          type: string
          example: 'Customer Payment'
        description:
          description: 'Human-readable description'
          type: string
          nullable: true
        status:
          type: string
          enum:
            - active
            - deprecated
          example: active
        version:
          description: 'Version number (increments on update)'
          type: integer
          example: 1
        params_schema:
          oneOf:
            -
              $ref: '#/components/schemas/ParamsSchema'
          nullable: true
        entries_template:
          $ref: '#/components/schemas/EntriesTemplate'
        attributes_template:
          oneOf:
            -
              $ref: '#/components/schemas/AttributesTemplate'
          nullable: true
        validation_rules:
          type: object
          nullable: true
        metadata:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      type: object
    AccountBalanceResponse:
      title: 'Account Balance Response'
      description: 'Layered balance snapshot for a single account. Per issue #86, "available" was removed — only settled funds are spendable. Encumbrances are split into overdue (past due date) and future (due today or later).'
      required:
        - success
        - data
      type: object
      allOf:
        -
          $ref: '#/components/schemas/SuccessEnvelope'
        -
          properties:
            success:
              description: 'Structured response for account balance endpoint'
              type: boolean
              example: true
            data:
              required:
                - account_id
                - account_code
                - account_name
                - account_type
                - normal_balance
                - balances
                - layer_details
                - currency
                - as_of_date
                - calculated_at
              properties:
                account_id:
                  type: string
                  format: uuid
                  example: 01234567-89ab-cdef-0123-456789abcdef
                account_code:
                  type: string
                  example: '1000'
                account_name:
                  type: string
                  example: Cash
                account_type:
                  type: string
                  enum:
                    - asset
                    - liability
                    - equity
                    - revenue
                    - expense
                  example: asset
                normal_balance:
                  type: string
                  enum:
                    - debit
                    - credit
                  example: debit
                balances:
                  required:
                    - settled
                    - pending
                    - overdue
                    - future
                  properties:
                    settled:
                      description: 'Confirmed cleared funds — what you can spend'
                      type: string
                      example: '1000.00'
                    pending:
                      description: 'Unconfirmed transactions (not yet spendable)'
                      type: string
                      example: '50.00'
                    overdue:
                      description: 'Encumbrances past their due date'
                      type: string
                      example: '20.00'
                    future:
                      description: 'Encumbrances due today or later'
                      type: string
                      example: '80.00'
                  type: object
                layer_details:
                  description: 'Detailed breakdown per accounting layer'
                  type: object
                currency:
                  type: string
                  example: GBP
                as_of_date:
                  type: string
                  format: date
                  example: '2025-01-15'
                calculated_at:
                  type: string
                  format: date-time
              type: object
          type: object
    ErrorEnvelope:
      title: 'Error Envelope'
      description: 'Uniform error response envelope returned for all 4xx and 5xx responses.'
      required:
        - success
        - message
      properties:
        success:
          description: 'Standard error response for API endpoints'
          type: boolean
          example: false
        message:
          description: 'Human-readable error summary.'
          type: string
          example: 'Validation failed'
        code:
          description: 'Canonical machine-readable error code.'
          type: string
          example: VALIDATION_ERROR
          nullable: true
        errors:
          description: 'Optional per-field validation errors keyed by field name.'
          type: object
          nullable: true
          additionalProperties:
            type: array
            items:
              type: string
        meta:
          description: 'Optional metadata (e.g. retry_after for rate limits).'
          type: object
          nullable: true
      type: object
    ExchangeRateQuote:
      title: 'Resolved Exchange Rate'
      description: 'A resolved rate for a pair: the decimal ratio, its inverse, provenance, effective time, and a stale flag.'
      required:
        - base_currency
        - quote_currency
        - rate
        - inverse_rate
        - source
        - effective_at
        - stale
      properties:
        base_currency:
          description: "Schema + envelope factory for a RESOLVED current/historical rate (#103) — the\noutput of {@see \\App\\Services\\Fx\\ExchangeRateService::getCurrentRate()}.\n\nDistinct from {@see ExchangeRateResource}, which renders a stored DB row: a resolved\nquote may be a synthetic 1:1 (same currency) or a just-fetched live rate that also\ncarries the `stale` flag. Kept a plain holder (does NOT extend {@see SuccessResponse})\nso the `#[OA\\Schema]` resolves with its explicit properties intact."
          type: string
          example: EUR
        quote_currency:
          type: string
          example: USD
        rate:
          description: '1 base_currency = rate quote_currency.'
          type: string
          example: '1.081000000000000000'
        inverse_rate:
          type: string
          example: '0.925069380203515263'
        source:
          description: 'manual | ecb | identity (same currency) | …'
          type: string
          example: ecb
        effective_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        stale:
          description: 'True when the rate is older than the configured staleness threshold.'
          type: boolean
          example: false
      type: object
    FxConversion:
      title: 'FX Conversion Result'
      description: 'The result of converting an amount between two currencies at a resolved rate. Nothing is posted.'
      required:
        - from
        - to
        - amount
        - converted
        - rate
        - inverse_rate
        - source
        - effective_at
        - stale
      properties:
        from:
          description: "Schema + envelope factory for the conversion calculator\n(`GET /api/v1/fx/convert`), #103.\n\nDescribes an amount converted from one currency to another at a resolved rate.\nIt is a pure calculation — nothing is posted. Amounts are major-unit STRINGS.\nKept a plain holder (does NOT extend {@see SuccessResponse}) so the `#[OA\\Schema]`\nresolves with its explicit properties intact."
          type: string
          example: USD
        to:
          type: string
          example: EUR
        amount:
          description: 'The input amount in `from` (major units).'
          type: string
          example: '100.00'
        converted:
          description: 'The converted amount in `to` (major units).'
          type: string
          example: '92.00'
        rate:
          description: '1 from = rate to.'
          type: string
          example: '0.920000000000000000'
        inverse_rate:
          type: string
          example: '1.086956521739130435'
        source:
          type: string
          example: ecb
        effective_at:
          type: string
          format: date-time
        stale:
          type: boolean
          example: false
      type: object
    StringOperationResult:
      title: 'String Operation Result'
      description: 'The committed pair produced by a cross-ledger string transfer/convert: the shared correlation id and both legs.'
      required:
        - operation
        - string_code
        - correlation_id
        - replayed
        - source
        - target
      properties:
        operation:
          description: 'The cross-ledger operation that was posted.'
          type: string
          enum:
            - transfer
            - convert
        string_code:
          description: 'The string the operation was posted against.'
          type: string
          example: treasury-fx
        correlation_id:
          description: 'Shared handle stamped on BOTH leg transactions; groups the pair across ledgers.'
          type: string
          format: uuid
        replayed:
          description: 'True when this idempotency key had already posted: the original result is returned and nothing was posted again.'
          type: boolean
          example: false
        source:
          description: 'The leg on the source ledger (value leaves the customer account here).'
          required:
            - ledger_code
            - ledger_id
            - transaction_id
            - reference
            - amount
          properties:
            ledger_code:
              description: 'Code of the source member ledger.'
              type: string
              example: usd-main
            ledger_id:
              description: 'Id of the source member ledger.'
              type: string
              format: uuid
            transaction_id:
              description: 'Id of the posted source-leg transaction.'
              type: string
              format: uuid
            reference:
              description: 'The per-ledger transaction reference of the source leg.'
              type: string
              example: FX-2026-0001
            amount:
              description: 'Source-leg amount in the source currency (major units).'
              type: string
              example: '100.00'
          type: object
        target:
          description: 'The leg on the target ledger (value arrives in the customer account here).'
          required:
            - ledger_code
            - ledger_id
            - transaction_id
            - reference
            - amount
          properties:
            ledger_code:
              description: 'Code of the target member ledger.'
              type: string
              example: eur-main
            ledger_id:
              description: 'Id of the target member ledger.'
              type: string
              format: uuid
            transaction_id:
              description: 'Id of the posted target-leg transaction.'
              type: string
              format: uuid
            reference:
              description: 'The per-ledger transaction reference of the target leg.'
              type: string
              example: FX-2026-0001
            amount:
              description: 'Target-leg amount in the target currency (major units).'
              type: string
              example: '92.00'
          type: object
        rate:
          description: 'The authoritative conversion rate for a convert (audit only); null for a transfer.'
          type: string
          example: '0.92'
          nullable: true
      type: object
    StringBalanceLine:
      title: 'String Balance Line'
      description: 'The balance of an account code within a SINGLE member ledger. There is one line per member ledger — never collapsed by currency.'
      required:
        - ledger_id
        - ledger_code
        - currency
        - storage_decimals
        - display_decimals
        - balances
      properties:
        ledger_id:
          description: 'Id of the member ledger this balance line belongs to.'
          type: string
          format: uuid
        ledger_code:
          type: string
          example: gbp-main
        currency:
          type: string
          example: GBP
        storage_decimals:
          description: 'Storage precision of this member ledger'
          type: integer
          example: 2
        display_decimals:
          description: 'Display precision of this member ledger'
          type: integer
          example: 2
        balances:
          required:
            - settled
            - pending
          properties:
            settled:
              description: 'Confirmed cleared funds (major units)'
              type: string
              example: '1000.00'
            pending:
              description: 'Unconfirmed transactions (major units)'
              type: string
              example: '50.00'
          type: object
        materialized:
          description: 'Present and false ONLY on a synthesized zero line for a member that has not materialized the account. Omitted (implicit true) on real rows, including a soft-deleted account reported with a zero balance.'
          type: boolean
          example: false
      type: object
    StringUnifiedBalance:
      title: 'String Unified Balance'
      description: "An account code's balance across every member ledger of a string, one line per member ledger."
      required:
        - string_code
        - account_code
        - ledgers
      properties:
        string_code:
          type: string
          example: treasury-fx
        account_code:
          type: string
          example: '1000'
        ledgers:
          description: 'One balance line per member ledger, ordered by ledger_code.'
          type: array
          items:
            $ref: '#/components/schemas/StringBalanceLine'
        total_in_base_currency:
          description: 'Present ONLY when requested via `?include=total_in_base_currency` (#103): the sum of every member line converted to the base currency at the current FX rate. Indicative — computed from displayed member balances, so it moves with the rate.'
          required:
            - base_currency
            - settled
            - pending
          properties:
            base_currency:
              type: string
              example: USD
            settled:
              description: 'Total settled balance in the base currency (major units).'
              type: string
              example: '1234.56'
            pending:
              description: 'Total pending balance in the base currency (major units).'
              type: string
              example: '78.90'
          type: object
          nullable: true
      type: object
    SuccessEnvelope:
      title: 'Success Envelope'
      description: 'Uniform success response envelope. Endpoints supply a typed `data` property via `allOf` composition; this base schema only defines the shared fields.'
      required:
        - success
      properties:
        success:
          description: 'Standard success response for API endpoints'
          type: boolean
          example: true
        message:
          description: 'Optional human-readable confirmation message.'
          type: string
          nullable: true
        meta:
          description: 'Optional metadata (pagination, etc.).'
          type: object
          nullable: true
      type: object
  securitySchemes:
    sanctum:
      type: http
      description: 'Laravel Sanctum Bearer Token'
      bearerFormat: Sanctum
      scheme: bearer
tags:
  -
    name: 'Account Sets'
    description: 'Logical groupings of accounts for reporting'
  -
    name: Journals
    description: 'Transaction journals for organizing transactions'
  -
    name: Reports
    description: 'Financial reports and statements'
  -
    name: Accounts
    description: Accounts
  -
    name: 'Attribute Catalog'
    description: 'Attribute Catalog'
  -
    name: FX
    description: FX
  -
    name: Strings
    description: Strings
  -
    name: 'Transaction Codes'
    description: 'Transaction Codes'
  -
    name: Transactions
    description: Transactions
