openapi: 3.1.0
info:
  title: POSnavigator Filters and Offers API
  version: "1.0.0"
  description: |-
    Canonical contract for the programmatic wizard: discover filter
    option IDs, then POST the same search the public offer list uses.
    
    Pricing: each offer's prices.sumprice is a 48-month TCO total from the same
    engine as the UI, rounded to two decimals. It is not a monthly rate. ICC++
    tariffs already include interchange and scheme fees in the totals. directfees
    lists tariff definitions without per-row validity dates; duplicate names with
    different amounts are usually consecutive validity windows — do not merge by
    name; use _id. Products outside validfrom/validto are excluded.
    
    # The happy path
    
    1. GET /openapi/filters-offers.yaml
    2. GET /api/v1/filters — copy ObjectIds
    3. POST /api/v1/offers with those ids plus financial parameters
    4. Rank on prices[].sumprice (default sort) or sumscore
    
    Omitting a filter array means no restriction on that axis. s_income 0 or omitted
    skips the income-band filter. Non-admin keys only see banks they can access in
    the filters list.
    
    Send `X-Api-Key: <key>` on every operation except this document itself.
    Create a key at /user/api-keys. Format: `pn_` plus a random string. Copy it
    immediately; it is shown once. Default new keys include blog, legal, presetfilter,
    versus-landing, widget, agent-analytics and hero-analytics scopes. Missing scopes
    return 403 INSUFFICIENT_PERMISSIONS. Bank-scoped resources also require the key
    owner to be admin or listed on that bank; otherwise 403 FORBIDDEN.
    
    # Response envelope
    
    Success: `{ "success": true, "data": ... }`.
    Failure: `{ "success": false, "error": { "code", "message" } }`.
    Branch on error.code, never on error.message.
    
    # Error codes
    
    | code | status | what to do |
    | --- | --- | --- |
    | MISSING_API_KEY | 401 | add X-Api-Key; do not retry blindly |
    | INVALID_API_KEY | 401 | stop; ask a human for a new key |
    | INSUFFICIENT_PERMISSIONS | 403 | stop; this key lacks a required scope |
    | FORBIDDEN | 403 | stop or switch to a key with bank/admin access |
    | RATE_LIMIT_EXCEEDED | 429 | wait Retry-After seconds |
    | INTERNAL_ERROR | 500 | retry with backoff |
    
    # Rate limiting
    
    Every response after successful authentication carries X-RateLimit-Limit,
    X-RateLimit-Remaining and X-RateLimit-Reset. A 429 also carries Retry-After.
    The two 401s are emitted before rate-limit accounting. Default window is 100
    requests per rolling hour unless the key was configured otherwise.
    
    # Canonical host
    
    Use https://posnavigator.eu. Legacy .hu hosts redirect; authenticated writes
    should call .eu directly.
    
    Additional codes: INVALID_BODY 400.
servers:
  - url: https://posnavigator.eu
tags:
  - name: Discovery
  - name: Filters
  - name: Offers
security:
  - ApiKeyAuth: []
paths:
  /openapi/filters-offers.yaml:
    get:
      operationId: getFiltersOffersOpenApi
      tags: [Discovery]
      security: []
      summary: Public OpenAPI 3.1 description
      description: |-
        This document. Public: no API key, no rate limit. Fetch it first and treat
        it as the complete manual. Every schema, limit, example, error code and
        header an agent needs is here. Linked from GET /openapi/catalog.yaml.
        Data operations below require authentication as described in info.
      responses:
        '200':
          description: OpenAPI YAML
          content:
            application/yaml:
              schema:
                type: string
              example: 'openapi: 3.1.0'
  /api/v1/filters:
    get:
      operationId: getFilterOptions
      tags: [Filters]
      summary: List filter option ids for offer search
      description: |-
        Returns name+_id (+ translations) for every wizard dimension: banks,
        channels, features, languages, servicetypes, currencies, itproviders,
        payment_schemes, mccs, companytypes, companyprofiles, promotions.
        Copy these ids into POST /api/v1/offers. Bank list is scoped like the
        wizard (admin sees all production-eligible banks). Side-effect free.
      responses:
        '200':
          description: Filter dimensions
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FiltersResponse'
              example:
                success: true
                data:
                  banks:
                    - _id: '507f1f77bcf86cd799439011'
                      name: Example Bank
                      dba_name: Example Bank
                  channels: []
                  features: []
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
  /api/v1/offers:
    post:
      operationId: searchOffers
      tags: [Offers]
      summary: Run the wizard offer search
      description: |-
        Body mirrors wizard query params. Arrays of ObjectIds restrict that
        dimension; empty/omitted means no extra restriction. Financial numbers
        (s_income monthly card turnover, s_basket average basket, card-mix rates,
        terminal and webstore counts) feed the same price engine as the UI.
        sortBy is sumprice (default, lowest TCO first) or sumscore. locale is
        hu or en for names. Returns offers plus totalCount. s_accountreq defaults
        true (include account-required products). Directfees may repeat the same
        localized name for sequential validity windows — compare totals, not
        naive sums of those rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OffersSearchRequest'
            example:
              s_channels: ['507f1f77bcf86cd799439001']
              s_income: 1000000
              s_basket: 8000
              s_terminal_number: 1
              locale: hu
              sortBy: sumprice
      responses:
        '200':
          description: Ranked offers
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OffersResponse'
        '400':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          description: Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          description: Error
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |-
        Send `X-Api-Key: pn_...` on every data operation. Create a key at
        /user/api-keys; it is shown once. Inactive or expired keys return 401.
  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix epoch seconds when the window resets
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying a 429
      schema:
        type: integer
  parameters:
    BankId:
      name: bankId
      in: path
      required: true
      description: 24-character hex Mongo ObjectId of the bank
      schema:
        type: string
        pattern: '^[a-fA-F0-9]{24}$'
        maxLength: 24

  schemas:
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              maxLength: 80
              description: Stable machine code. Branch on this, never on message.
            message:
              type: string
              maxLength: 2000
            details:
              type: array
              maxItems: 50
              items:
                type: string
                maxLength: 500
            retryAfter:
              type: integer
              description: Present on RATE_LIMIT_EXCEEDED
            field_errors:
              type: object
              additionalProperties:
                type: array
                maxItems: 20
                items:
                  type: string
                  maxLength: 500
    LocalePair:
      type: object
      additionalProperties: false
      properties:
        hu:
          type: string
          maxLength: 20000
        en:
          type: string
          maxLength: 20000
    OffsetPagination:
      type: object
      additionalProperties: false
      required: [total, limit, offset, hasMore]
      properties:
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    FilterItem:
      type: object
      additionalProperties: true
      required: [_id]
      properties:
        _id: { type: string, maxLength: 24 }
        name: { type: string, maxLength: 300 }
        dba_name: { type: string, maxLength: 300 }
        translations:
          type: object
          additionalProperties: true
    FiltersResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: true
          properties:
            banks:
              type: array
              maxItems: 2000
              items: { $ref: '#/components/schemas/FilterItem' }
            channels:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            features:
              type: array
              maxItems: 2000
              items: { $ref: '#/components/schemas/FilterItem' }
            languages:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            servicetypes:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            currencies:
              type: array
              maxItems: 50
              items: { $ref: '#/components/schemas/FilterItem' }
            itproviders:
              type: array
              maxItems: 500
              items: { $ref: '#/components/schemas/FilterItem' }
            payment_schemes:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            mccs:
              type: array
              maxItems: 2000
              items: { $ref: '#/components/schemas/FilterItem' }
            companytypes:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            companyprofiles:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/FilterItem' }
            promotions:
              type: array
              maxItems: 500
              items: { $ref: '#/components/schemas/FilterItem' }
    OffersSearchRequest:
      type: object
      additionalProperties: true
      properties:
        s_banks:
          type: array
          maxItems: 100
          items: { type: string, maxLength: 24 }
          description: Restrict to these bank ids
        s_itproviders:
          type: array
          maxItems: 100
          items: { type: string, maxLength: 24 }
        s_channels:
          type: array
          maxItems: 50
          items: { type: string, maxLength: 24 }
        s_servicetypes:
          type: array
          maxItems: 50
          items: { type: string, maxLength: 24 }
        s_features:
          type: array
          maxItems: 200
          items: { type: string, maxLength: 24 }
        s_languages:
          type: array
          maxItems: 50
          items: { type: string, maxLength: 24 }
        s_payment_schemes:
          type: array
          maxItems: 50
          items: { type: string, maxLength: 24 }
        s_currency:
          type: array
          maxItems: 20
          items: { type: string, maxLength: 24 }
        s_companytypes:
          type: array
          maxItems: 50
          items: { type: string, maxLength: 24 }
        s_mcc:
          type: array
          maxItems: 100
          items: { type: string, maxLength: 24 }
        s_devicefeatures:
          type: array
          maxItems: 100
          items: { type: string, maxLength: 24 }
        s_income:
          type: number
          description: Monthly card turnover HUF; 0 skips income-band filter
        s_basket:
          type: number
          description: Average basket HUF
        s_cardspent:
          type: number
        s_commercial_card_rate: { type: number }
        s_noneu_card_rate: { type: number }
        s_qvik_rate: { type: number }
        s_eu_card_rate: { type: number }
        s_credit_card_rate: { type: number }
        s_terminal_number:
          type: number
          description: POS terminal count, default 1
        s_webstore_number:
          type: number
          description: Webstore count, default 1
        s_companyprofile: { type: string, maxLength: 24 }
        s_currentbank: { type: string, maxLength: 24 }
        s_promotion: { type: string, maxLength: 24 }
        s_accountreq:
          type: boolean
          description: Default true; include account-required products
        s_iscard: { type: boolean }
        s_enable_non_eu: { type: boolean }
        s_enable_eu: { type: boolean }
        s_enable_corporate: { type: boolean }
        s_enable_credit: { type: boolean }
        locale:
          type: string
          enum: [hu, en]
          description: Name language, default hu
        sortBy:
          type: string
          enum: [sumprice, sumscore]
          description: Default sumprice (lowest 48-month TCO first)
    OfferItem:
      type: object
      additionalProperties: true
      required: [productId, prices]
      properties:
        productId: { type: string, maxLength: 24 }
        name:
          type: object
          additionalProperties: { type: string, maxLength: 500 }
        sumscore: { type: number }
        prices:
          type: array
          maxItems: 10
          items:
            type: object
            additionalProperties: true
            properties:
              sumprice:
                type: number
                description: 48-month TCO total, not monthly
              transactionprice: { type: number }
              monthlyprice: { type: number }
              oneoffprice: { type: number }
              annualprice: { type: number }
        directfees:
          type: array
          maxItems: 200
          items:
            type: object
            additionalProperties: true
            properties:
              _id: { type: string, maxLength: 24 }
              name:
                type: object
                additionalProperties: { type: string, maxLength: 300 }
              value_percentage:
                type: [number, 'null']
              value_fixed: { type: number }
        isorderable: { type: boolean }
        bank:
          type: object
          additionalProperties: true
    OffersResponse:
      type: object
      required: [success, data]
      additionalProperties: false
      properties:
        success: { type: boolean, const: true }
        data:
          type: object
          additionalProperties: false
          required: [offers, totalCount, sortBy]
          properties:
            offers:
              type: array
              maxItems: 500
              items: { $ref: '#/components/schemas/OfferItem' }
            totalCount: { type: integer }
            sortBy: { type: string, maxLength: 40 }

