Architecture

Tok technical architecture, Kotlin/Ktor, bank adapter pattern, GCP infrastructure

Tech Stack

Tech Stack

Tok is a Kotlin-native backend built for reliability and financial-grade security.


Core Stack

Layer Technology Notes
Language Kotlin JVM-based, coroutine-native
HTTP Framework Ktor Kotlin-idiomatic, coroutines-native routing
Dependency Injection Koin Lightweight, Kotlin-first DI
Database PostgreSQL Primary data store
ORM Exposed (Kotlin SQL framework) Type-safe SQL DSL
Connection Pooling HikariCP High-performance JDBC pool
DB Migrations Flyway Version-controlled schema migrations
Job Scheduling Quartz Scheduler + coroutines Bank sync scheduling
Serialization kotlinx.serialization Native Kotlin JSON
Build Gradle (Kotlin DSL) Multi-module project

Security & Encryption

Concern Technology
Token encryption AES-256-GCM
Key management GCP Cloud KMS (HSM-backed)
PSD2 mTLS (QWAC) DigiCert or GlobalSign certificate
CSRF protection Cryptographic random state parameter per consent
Secret storage GCP Secret Manager

Token encryption flow:

1. Receive OAuth token from bank API
2. Call GCP Cloud KMS generateDataKey (DEK + encrypted DEK)
3. Encrypt token with DEK (AES-256-GCM, random IV)
4. Store: encrypted_dek + iv + ciphertext in PostgreSQL
5. DEK discarded from memory after use

QWAC private key is stored in GCP Cloud KMS HSM — never extracted to filesystem.


Testing

Tool Purpose
Kotest Primary test framework (BDD-style)
MockK Kotlin-idiomatic mocking
Testcontainers Ephemeral PostgreSQL for integration tests

Cloud Infrastructure — GCP

Service Purpose
Cloud Run API server deployment (serverless containers)
Cloud SQL Managed PostgreSQL
Cloud KMS HSM-backed key management for OAuth tokens
Secret Manager QWAC certs, API credentials

Data residency: europe-north1 (Finland) — covers EU/GDPR requirements for Croatian data, and PDPL-equivalent requirements for Serbian data.


API Design

Aspect Choice
Style REST + OpenAPI 3.1
Auth API keys (server-to-server) + OAuth2 (PSD2 consent flows)
Multi-tenant Organisation-scoped — each client = one organisation
Rate limiting Per-organisation, tiered: Free / Pro / Enterprise

Core endpoints:


Project Structure

Tok/
├── api/                        # Ktor API server (Gradle module)
│   └── src/
│       ├── main/kotlin/io/tokapi/
│       │   ├── Application.kt       # Ktor entry point
│       │   ├── adapters/             # BerlinGroupAdapter, BilateralAdapter
│       │   ├── consent/              # PSD2 consent management
│       │   ├── routes/               # Ktor routing
│       │   ├── services/             # Business logic
│       │   ├── models/               # Domain models + Exposed tables
│       │   └── plugins/              # Auth, rate-limit, logging, serialization
│       └── test/kotlin/io/tokapi/
├── sdk-kotlin/                 # Kotlin client SDK (for Bilko, Drop)
├── sdk-node/                   # Node.js client SDK (for third parties)
├── shared/                     # Shared domain types
├── docs/                       # Documentation
├── infrastructure/
│   ├── docker-compose.yml
│   └── terraform/              # GCP infrastructure as code
├── design/figma/
├── build.gradle.kts            # Root Gradle build
├── settings.gradle.kts         # Multi-module config
└── Dockerfile

SDKs

SDK Language Package
sdk-kotlin/ Kotlin io.tokapi:sdk-kotlin
sdk-node/ TypeScript @tokapi/sdk
packages/sdk-python/ Python 3.10+ tokapi-sdk

Bank Adapter Pattern

Bank Adapter Pattern

The bank adapter pattern is a core architectural principle in Tok. Every bank integration goes through an abstract BankAdapter interface, isolating the rest of the system from bank-specific API differences.


Why This Pattern?

Banks across Croatia, Serbia, and BiH use different API standards:

The adapter pattern hides this complexity behind one interface.


Abstract Interface

interface BankAdapter {
    /** Initiate OAuth consent flow — returns redirect URL */
    suspend fun initiateConsent(
        organizationId: String,
        callbackUrl: String
    ): ConsentRequest

    /** Exchange auth code for access + refresh tokens */
    suspend fun exchangeCode(code: String, state: String): OAuthTokens

    /** Refresh access token using refresh token */
    suspend fun refreshToken(refreshToken: String): OAuthTokens

    /** Fetch transactions for a date range */
    suspend fun fetchTransactions(
        accessToken: String,
        accountId: String,
        fromDate: LocalDate,
        toDate: LocalDate
    ): List<BankTransaction>

    /** Fetch account balance */
    suspend fun fetchBalance(
        accessToken: String,
        accountId: String
    ): BigDecimal

    /** Revoke consent */
    suspend fun revokeConsent(accessToken: String, consentId: String)
}

Implementations

BerlinGroupAdapter

Implements the Berlin Group NextGenPSD2 standard.

Used for:

Standard endpoints:

Required headers:

Authorization: Bearer {access_token}
X-Request-ID:  {uuid}          ← unique per request
PSU-IP-Address: {user-ip}      ← required by some banks

Auth flow: OAuth 2.0 Authorization Code Grant + SCA redirect.


BilateralAdapter

Implements per-bank custom REST integrations for banks without a central standard.

Used for:

Each bilateral bank gets its own BilateralAdapter subclass with custom field mapping — the interface contract remains identical.


Bank Registry

val BANK_REGISTRY: Map<String, BankAdapterConfig> = mapOf(
    // Croatia (Berlin Group)
    "addiko-hr"    to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://oapideveloper.addiko.hr"
    ),
    "erste-hr"     to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://developers.erstegroup.com"
    ),
    "hpb-hr"       to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://openbanking.hpb.hr"
    ),
    "otp-hr"       to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://api.otpbanka.hr"
    ),
    "pbz-hr"       to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://apiportal.pbz.hr"
    ),
    "raiffeisen-hr" to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://sandbox.rba.hr"
    ),
    "zaba-hr"      to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://developer.unicredit.eu"
    ),
    // Serbia — EU groups (Berlin Group)
    "nlb-rs"       to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://developer.nlbkb.rs"
    ),
    "unicredit-rs" to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://developer.unicredit.eu"
    ),
    "raiffeisen-rs" to BankAdapterConfig(
        adapter = "BerlinGroup",
        baseUrl = "https://api.rbinternational.com"
    ),
    // Domestic Serbian banks — added as bilateral agreements are established
)

Transaction Normalization

Regardless of which adapter is used, all transactions are normalized to the internal BankTransaction format before being stored. This is the adapter's primary responsibility.

Internal format fields:

Field Type Source
externalId String Bank's own transaction ID (dedup key)
bookingDate LocalDate Berlin Group bookingDate
valueDate LocalDate Berlin Group valueDate
amount NUMERIC(19,4) Normalized — never float
currency CurrencyCode ISO 4217
direction inbound/outbound Derived from credit/debit indicator
creditorIban String? Berlin Group creditorAccount.iban
debtorIban String? Berlin Group debtorAccount.iban
remittanceInfo String? remittanceInformationUnstructured
source String open_banking (vs manual or csv_import)

Adding a New Bank

  1. Determine which adapter applies (Berlin Group or bilateral)
  2. Add entry to BANK_REGISTRY with baseUrl, authUrl, scopes
  3. If bilateral — implement custom fetchTransactions() field mapping
  4. Register sandbox credentials and test against bank sandbox
  5. Add bank to UI: logo, display name, supported countries
  6. Test deduplication against externalId + bankAccountId unique constraint

Development Rules

Development Rules

Seven mandatory rules for all Tok development. These rules exist because financial data and PSD2 compliance leave no room for shortcuts.


Never update or delete a consent record. Each state change creates a new log entry:


Rule 2 — Token Encryption Mandatory

AES-256-GCM + GCP Cloud KMS for ALL OAuth tokens. No exceptions.

CORRECT:  Store tokens via Cloud KMS envelope encryption → encrypted_dek + iv + ciphertext in DB
WRONG:    Store raw tokens in DB or env vars
WRONG:    Store tokens in logs, files, or memory beyond request lifecycle

QWAC private key must also live in GCP Cloud KMS HSM — signing is done via Cloud KMS API, the key is never extracted.


Rule 3 — Bank Adapter Pattern

Every bank integration goes through the abstract BankAdapter interface.

// Correct
class BerlinGroupAdapter : BankAdapter { ... }
class BilateralAdapter   : BankAdapter { ... }

// Wrong — never call bank HTTP endpoints directly from services/routes

The adapter is the only layer that knows about bank-specific API formats. Services above it work only with normalized BankTransaction objects.


Rule 4 — Deduplication via externalId

externalId (bank's own transaction ID) + bankAccountId = unique constraint.

Duplicate imports are silently skipped — this is intentional, not an error. Never create duplicate transactions.

ALTER TABLE bank_transactions
ADD CONSTRAINT uq_bank_account_external
UNIQUE (bank_account_id, external_id);

Rule 5 — Money = NUMERIC(19,4)

Never use float or double for financial amounts.

// Correct
val amount: BigDecimal  // Kotlin
// Correct in DB
amount NUMERIC(19,4)

// Wrong
val amount: Double  // loses precision
val amount: Float   // loses precision

All amounts from bank APIs must be parsed to BigDecimal before storage. Amount equality comparisons use exact decimal matching.


Rule 6 — CSRF on Consent

// Correct
val state = java.security.SecureRandom()
    .generateSeed(32)
    .toHexString()
// Store in server-side session (NOT cookie, NOT localStorage)
// Validate on callback — reject if mismatch
// One-time use — invalidate after successful exchange

// Wrong
val state = UUID.randomUUID().toString()  // too predictable
val state = "fixed-string"               // completely insecure

Rule 7 — 90-Day Consent Tracking

Every BankConnection must have automated expiry monitoring.

PSD2 (EBA RTS Art. 10) requires re-authentication every 90 days.

Mandatory implementation:

Without this, bank feed silently breaks for ALL users when consents expire simultaneously.


Summary

Rule Enforcement
1. Consent immutability Code review — no UPDATE/DELETE on consent tables
2. Token encryption No raw token strings in code/DB/logs
3. Bank adapter pattern No direct HTTP bank calls outside adapter layer
4. Deduplication DB unique constraint enforced
5. Money = NUMERIC(19,4) No float/double for amounts anywhere
6. CSRF on consent State parameter required in every consent initiation
7. 90-day tracking Daily cron + email notification mandatory