openapi: 3.1.0

info:
  title: OpenDinar API
  version: 1.0.0
  description: |
    Open banking infrastructure for Serbia. Connect your app to Serbian bank accounts
    — read balances, transactions, identity, and recurring payments.

    **Base URL:** `https://api.opendinar.com`

    **Authentication:** All protected endpoints require a Bearer token in the `Authorization` header.
    Use your API key directly, or a short-lived `link_token` for frontend flows.

    **Sandbox keys:**
    - `od_test_sk_9x2mABC123sandbox` — Mani App (full access)
    - `od_test_sk_demo456developer` — Demo Developer

  contact:
    name: OpenDinar Support
    url: https://api.opendinar.com/docs
  license:
    name: Proprietary

servers:
  - url: https://api.opendinar.com
    description: Production (sandbox data)
  - url: http://localhost:3000
    description: Local development

tags:
  - name: Banks
    description: List and search supported Serbian banks
  - name: Connect
    description: Bank authorization flow — link a user's bank account
  - name: Connections
    description: Manage active bank connections
  - name: Accounts
    description: Access account data — balances, identity, auth, sync
  - name: Transactions
    description: Fetch and sync transaction history
  - name: Recurring
    description: Detect recurring payments and income
  - name: Developers
    description: Developer account management — register, login, API keys, webhooks, usage

# ─── Security ──────────────────────────────────────────────────────────────────

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Your API key or a short-lived link_token

  schemas:

    Bank:
      type: object
      properties:
        id:               { type: string, example: raiffeisen }
        name:             { type: string, example: Raiffeisen Bank }
        country:          { type: string, example: RS }
        bic:              { type: string, example: RZBSRSBG }
        logo:             { type: string, example: /logos/raiffeisen.svg }
        status:           { type: string, enum: [live, sandbox, coming_soon] }
        api_type:         { type: string, example: psd2_oauth2 }
        auth_type:        { type: string, example: oauth2 }
        color:            { type: string, example: '#FFDD00' }
        founded:          { type: integer, example: 2001 }
        branches:         { type: integer, example: 82 }
        market_share_pct: { type: number, example: 14.2 }
        features:
          type: object
          properties:
            accounts:     { type: boolean }
            transactions: { type: boolean }
            balance:      { type: boolean }
            identity:     { type: boolean }

    Account:
      type: object
      properties:
        id:                { type: string, example: acc_rai_001 }
        bank_id:           { type: string, example: raiffeisen }
        name:              { type: string, example: Raiffeisen Tekući Račun }
        iban:              { type: string, example: RS35265080123456789101 }
        currency:          { type: string, example: RSD }
        type:              { type: string, enum: [checking, savings, business] }
        balance:           { type: number, example: 245320.50 }
        available_balance: { type: number, example: 240000.00 }

    Transaction:
      type: object
      properties:
        id:                       { type: string, example: txn_009 }
        account_id:               { type: string, example: acc_rai_001 }
        date:                     { type: string, format: date, example: '2026-03-28' }
        amount:                   { type: number, example: -1250.00, description: "Negative = debit (money out), positive = credit (money in)" }
        currency:                 { type: string, example: RSD }
        description:              { type: string, example: 'MAXI SUPERMARKET BEOGRAD' }
        merchant_name:            { type: string, example: Maxi, description: "Clean merchant name — enriched automatically from raw description" }
        merchant_category:        { type: string, example: groceries, description: "Merchant-level category — more specific than bank category" }
        category:                 { type: string, example: groceries }
        type:                     { type: string, enum: [debit, credit] }
        pending:                  { type: boolean, example: false, description: "True if transaction is not yet fully settled by the bank" }
        pending_transaction_id:   { type: string, nullable: true, example: null, description: "Links a pending transaction to its posted version once settled" }

    Balance:
      type: object
      properties:
        object:            { type: string, example: balance }
        account_id:        { type: string, example: acc_rai_001 }
        currency:          { type: string, example: RSD }
        balance:           { type: number, example: 245320.50 }
        available_balance: { type: number, example: 240000.00 }
        retrieved_at:      { type: string, format: date-time }

    Identity:
      type: object
      properties:
        object:     { type: string, example: identity }
        account_id: { type: string }
        bank_id:    { type: string }
        identity:
          type: object
          properties:
            full_name: { type: string, example: Marko Petrović }
            phone:     { type: string, example: '+381601234567', nullable: true }
            email:     { type: string, example: marko@example.rs, nullable: true }
            address:   { type: string, example: 'Knez Mihailova 1, Beograd', nullable: true }

    Auth:
      type: object
      properties:
        object:     { type: string, example: auth }
        account_id: { type: string }
        bank_id:    { type: string }
        bank_name:  { type: string, example: Raiffeisen Bank }
        numbers:
          type: object
          description: The two values needed to initiate a SEPA payment to this account
          properties:
            iban:     { type: string, example: RS35265080123456789101 }
            bic:      { type: string, example: RZBSRSBG }
            currency: { type: string, example: RSD }
        account_holder:
          type: object
          properties:
            name:     { type: string, example: Marko Petrović }
            verified: { type: boolean, example: true }
        account_type: { type: string, enum: [checking, savings, business] }
        retrieved_at: { type: string, format: date-time }

    RecurringPayment:
      type: object
      properties:
        merchant_name:       { type: string, example: Netflix }
        category:            { type: string, example: subscriptions }
        average_amount:      { type: number, example: -699.00 }
        currency:            { type: string, example: RSD }
        frequency:           { type: string, enum: [monthly, weekly, annual, irregular] }
        last_date:           { type: string, format: date }
        next_expected_date:  { type: string, format: date, nullable: true }
        occurrences:         { type: integer, example: 3 }
        status:              { type: string, enum: [active, cancelled] }

    Error:
      type: object
      properties:
        error_type:    { type: string, example: INVALID_REQUEST }
        error_code:    { type: string, example: MISSING_FIELDS }
        error_message: { type: string, example: 'The field bank_id is required.' }
        docs:          { type: string, example: 'https://api.opendinar.com/docs/errors#missing_fields' }
        request_id:    { type: string, example: 'a1b2c3d4-...' }

    LinkToken:
      type: object
      properties:
        object:     { type: string, example: link_token }
        link_token: { type: string, example: 'lt_abc123...' }
        expires_in: { type: integer, example: 1800 }
        expires_at: { type: string, format: date-time }
        usage:      { type: string }

    WebhookEvent:
      type: object
      description: |
        All webhook payloads are signed with HMAC-SHA256.
        Verify using the header: `OpenDinar-Signature: t=<timestamp>,v1=<hmac>`
      properties:
        event_type:  { type: string, example: TRANSACTIONS.SYNC_UPDATES_AVAILABLE }
        api_key_prefix: { type: string, example: od_test_sk_9x2m }
        timestamp:   { type: string, format: date-time }
        data:        { type: object }


# ─── Paths ─────────────────────────────────────────────────────────────────────

paths:

  # ── Banks ────────────────────────────────────────────────────────────────────

  /banks:
    get:
      tags: [Banks]
      summary: List all supported banks
      description: Returns all Serbian banks OpenDinar supports. No API key required.
      security: []
      parameters:
        - in: query
          name: search
          schema: { type: string }
          description: Filter by bank name (partial match)
        - in: query
          name: status
          schema: { type: string, enum: [live, sandbox, coming_soon] }
          description: Filter by integration status
        - in: query
          name: limit
          schema: { type: integer, default: 20 }
        - in: query
          name: offset
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: List of banks
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: list }
                  count:  { type: integer }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Bank' }

  /banks/{id}:
    get:
      tags: [Banks]
      summary: Get a single bank
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: Bank ID (e.g. `raiffeisen`, `erste`, `aik`)
      responses:
        '200':
          description: Bank object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Bank' }
        '404':
          description: Bank not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ── Link Token ───────────────────────────────────────────────────────────────

  /link/token/create:
    post:
      tags: [Connect]
      summary: Create a link token
      description: |
        **Server-side only.** Call this from your backend with your real API key.
        Returns a short-lived `link_token` (30 minutes) to pass to the Link Widget on your frontend.
        This keeps your API key off the browser entirely.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: string
                  description: Your internal user identifier — stored for reference
                  example: user_123
      responses:
        '200':
          description: Link token created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LinkToken' }

  # ── Connect ──────────────────────────────────────────────────────────────────

  /connect:
    post:
      tags: [Connect]
      summary: Start bank authorization
      description: |
        Step 1 of the connection flow. Returns a `session_token` and an `auth_url`
        to redirect the user to (or open in a popup). Accepts either an API key or a `link_token`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bank_id, user_id]
              properties:
                bank_id:
                  type: string
                  example: raiffeisen
                user_id:
                  type: string
                  example: user_123
      responses:
        '200':
          description: Session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:        { type: string, example: connect_session }
                  session_token: { type: string, example: 'sess_abc123...' }
                  auth_url:      { type: string, example: 'https://api.opendinar.com/sandbox/auth.html?token=...' }
                  expires_in:    { type: integer, example: 600 }
                  expires_at:    { type: string, format: date-time }

  /connect/{session_token}/status:
    get:
      tags: [Connect]
      summary: Check authorization status
      description: |
        Step 2. Poll this after the user completes bank authorization.
        Once status is `authorized`, you'll receive a one-time `public_token`.
      parameters:
        - in: path
          name: session_token
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Session status
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:       { type: string, example: connect_status }
                  status:       { type: string, enum: [pending, authorized, expired] }
                  public_token: { type: string, nullable: true, description: "Only present when status = authorized. One-time use — exchange immediately." }

  /connect/exchange:
    post:
      tags: [Connect]
      summary: Exchange public_token for connection_id
      description: |
        Step 3 (final). Exchange the one-time `public_token` for a permanent `connection_id`.
        Store the `connection_id` — you'll use it to identify this user's bank connection.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [public_token]
              properties:
                public_token: { type: string, example: 'public_abc123...' }
      responses:
        '200':
          description: Connection established
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:        { type: string, example: connection }
                  connection_id: { type: string, example: 'conn_abc123...' }
                  bank_id:       { type: string, example: raiffeisen }
                  status:        { type: string, example: active }
                  connected_at:  { type: string, format: date-time }

  # ── Connections ──────────────────────────────────────────────────────────────

  /connections:
    get:
      tags: [Connections]
      summary: List all connections
      description: Returns all active bank connections created by this API key.
      responses:
        '200':
          description: List of connections
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: list }
                  count:  { type: integer }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        connection_id: { type: string }
                        bank_id:       { type: string }
                        user_id:       { type: string }
                        status:        { type: string }
                        connected_at:  { type: string, format: date-time }

  /connections/{id}:
    delete:
      tags: [Connections]
      summary: Revoke a connection
      description: |
        Permanently disconnects a user's bank account. Fires the `ITEM.CONNECTION_REVOKED` webhook.
        Use this to comply with GDPR right-to-erasure requests.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: The connection_id to revoke
      responses:
        '200':
          description: Connection revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:        { type: string, example: connection }
                  connection_id: { type: string }
                  status:        { type: string, example: revoked }
                  revoked_at:    { type: string, format: date-time }

  # ── Accounts ─────────────────────────────────────────────────────────────────

  /accounts:
    get:
      tags: [Accounts]
      summary: List accounts
      description: Returns all bank accounts accessible to this API key. Isolated — you only see your own users' accounts.
      parameters:
        - in: query
          name: bank_id
          schema: { type: string }
          description: Filter by bank
      responses:
        '200':
          description: List of accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: list }
                  count:  { type: integer }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Account' }

  /accounts/{id}/balance:
    get:
      tags: [Accounts]
      summary: Get account balance
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Balance object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Balance' }
        '404':
          description: Account not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /accounts/{id}/identity:
    get:
      tags: [Accounts]
      summary: Get account holder identity
      description: Returns the account holder's name, phone, email, and address. Only available if the bank supports identity data.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Identity object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Identity' }
        '404':
          description: Account not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /accounts/{id}/auth:
    get:
      tags: [Accounts]
      summary: Get verified IBAN + BIC for payment initiation
      description: |
        Returns the verified IBAN and BIC for an account — the two values needed to
        initiate a SEPA payment. Serbian equivalent of Plaid's Auth product.

        Use this instead of asking the user to manually enter their account number.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Auth object with IBAN and BIC
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Auth' }
        '404':
          description: Account not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /accounts/{id}/sync:
    post:
      tags: [Accounts]
      summary: Force a fresh data pull
      description: |
        Triggers a fresh pull of transaction data from the bank.
        In sandbox, this resets the sync cursor so the next transaction fetch returns everything.
        Fires the `TRANSACTIONS.SYNC_UPDATES_AVAILABLE` webhook when complete.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Sync queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:      { type: string, example: sync_request }
                  account_id:  { type: string }
                  status:      { type: string, example: queued }
                  message:     { type: string }
                  refreshed_at: { type: string, format: date-time }

  # ── Transactions ─────────────────────────────────────────────────────────────

  /accounts/{id}/transactions:
    get:
      tags: [Transactions]
      summary: Get transactions
      description: |
        **Standard mode** (no cursor): Returns filtered transactions, newest first.

        **Sync mode** (with `?cursor=`): Returns only new transactions since the last call.
        Pass `next_cursor` from the previous response as the `cursor` on the next call.
        This is the most efficient way to keep transaction data up to date.

        All transactions are automatically enriched with `merchant_name` and `merchant_category`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: Account ID
        - in: query
          name: from
          schema: { type: string, format: date }
          description: Start date filter (YYYY-MM-DD)
        - in: query
          name: to
          schema: { type: string, format: date }
          description: End date filter (YYYY-MM-DD)
        - in: query
          name: category
          schema: { type: string }
          description: Filter by category (e.g. groceries, dining, transport)
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 500 }
        - in: query
          name: cursor
          schema: { type: string }
          description: Transaction ID to sync from. Omit for first call.
      responses:
        '200':
          description: Transactions (standard mode)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:     { type: string, example: list }
                  account_id: { type: string }
                  count:      { type: integer }
                  summary:
                    type: object
                    properties:
                      total_in:  { type: number }
                      total_out: { type: number }
                      net:       { type: number }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Transaction' }
        '200x':
          description: Transactions (sync mode, when cursor is provided)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:      { type: string, example: sync }
                  account_id:  { type: string }
                  sync_status: { type: string, example: HISTORICAL_UPDATE_COMPLETE }
                  added:
                    type: array
                    items: { $ref: '#/components/schemas/Transaction' }
                  modified:    { type: array, items: { $ref: '#/components/schemas/Transaction' } }
                  removed:     { type: array, items: { type: object } }
                  next_cursor: { type: string }
                  has_more:    { type: boolean }

  # ── Recurring ────────────────────────────────────────────────────────────────

  /accounts/{id}/recurring:
    get:
      tags: [Recurring]
      summary: Detect recurring payments and income
      description: |
        Analyses transaction history to detect recurring patterns —
        subscriptions, bills, rent, salary, and other regular payments.

        Requires at least 2–3 months of transaction history for reliable detection.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Recurring payments detected
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:     { type: string, example: recurring_summary }
                  account_id: { type: string }
                  outflows:
                    type: array
                    items: { $ref: '#/components/schemas/RecurringPayment' }
                  inflows:
                    type: array
                    items: { $ref: '#/components/schemas/RecurringPayment' }
                  summary:
                    type: object
                    properties:
                      total_monthly_outflows: { type: number }
                      total_monthly_inflows:  { type: number }
                      subscriptions_count:    { type: integer }

  # ── Developer Portal ─────────────────────────────────────────────────────────

  /developers/register:
    post:
      tags: [Developers]
      summary: Create a developer account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, password]
              properties:
                name:     { type: string, example: Jane Doe }
                email:    { type: string, format: email, example: jane@example.rs }
                password: { type: string, format: password, minLength: 8 }
                company:  { type: string, example: Acme d.o.o. }
      responses:
        '201':
          description: Account created with first API key

  /developers/login:
    post:
      tags: [Developers]
      summary: Log in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:    { type: string, format: email }
                password: { type: string, format: password }
      responses:
        '200':
          description: Returns session token
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:      { type: string, description: Session token — use as Bearer token for /developers/* endpoints }
                  developer:  { type: object }

  /developers/logout:
    post:
      tags: [Developers]
      summary: Log out
      description: Immediately invalidates the current session token.
      responses:
        '200':
          description: Logged out

  /developers/me:
    get:
      tags: [Developers]
      summary: Get your account details
      responses:
        '200':
          description: Developer account object

  /developers/keys:
    get:
      tags: [Developers]
      summary: List your API keys
      description: Returns all your keys — prefixes only, never the full key.
      responses:
        '200':
          description: List of API keys
    post:
      tags: [Developers]
      summary: Generate a new API key
      description: Returns the full key once — store it immediately, it cannot be shown again.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                app_name:    { type: string, example: My App }
                environment: { type: string, enum: [sandbox, production], default: sandbox }
      responses:
        '201':
          description: New API key (shown once)

  /developers/keys/{key}:
    delete:
      tags: [Developers]
      summary: Revoke an API key
      parameters:
        - in: path
          name: key
          required: true
          schema: { type: string }
          description: The API key to revoke
      responses:
        '200':
          description: Key revoked

  /developers/usage:
    get:
      tags: [Developers]
      summary: View usage stats and error logs
      description: Returns request counts by endpoint and recent error logs for your API keys.
      responses:
        '200':
          description: Usage stats

  /developers/webhooks:
    get:
      tags: [Developers]
      summary: List registered webhooks
      responses:
        '200':
          description: List of webhooks
    post:
      tags: [Developers]
      summary: Register a webhook URL
      description: |
        Register a URL to receive real-time event notifications.
        All payloads are signed with HMAC-SHA256 — verify using the `OpenDinar-Signature` header.

        **Events fired:**
        - `AUTH.AUTOMATICALLY_VERIFIED` — user completed bank authorization
        - `ITEM.CONNECTION_REVOKED` — a connection was revoked
        - `TRANSACTIONS.SYNC_UPDATES_AVAILABLE` — new transactions are ready to fetch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  example: https://yourapp.com/webhooks/opendinar
      responses:
        '201':
          description: Webhook registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:         { type: string }
                  url:        { type: string }
                  secret:     { type: string, description: "Shown once — use to verify webhook signatures" }
                  created_at: { type: string, format: date-time }

  /developers/webhooks/{id}:
    delete:
      tags: [Developers]
      summary: Deactivate a webhook
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Webhook deactivated
