Skip to content

Integrate Bank Frick (WebAPI) as a new bank — statement import + fiat payout #4193

Description

@TaprootFreak

Goal

DFX now holds corporate accounts at Bank Frick AG (Balzers, LI). Integrate Bank Frick as a new bank in the API, on par with the existing Olkypay / Yapeal / Raiffeisen integrations:

  1. Registry — register Bank Frick in the bank registry so it can be selected for receiving and sending.
  2. Inbound — import incoming account statements into bank_tx (customer deposits) via Bank Frick's WebAPI.
  3. Outbound — send fiat payouts through Bank Frick.

Bank Frick exposes a REST WebAPI (public docs: https://developers.bankfrick.li/api_docs/#introduction). Two mechanisms are new to our codebase: a two‑stage JWT auth and a per‑request RSA‑SHA512 body signature. Everything else (camt.053 parsing, pain.001 generation, the poll→bank_tx pipeline, the fiat‑output dispatch) can reuse existing building blocks.

This issue is written so it can be implemented from public sources only (this repo + the public Bank Frick API docs). All secret values (API key, RSA private key, concrete IBANs / customer number) are provided separately by the DFX team — see What the DFX team provides. Do not put any secret or concrete IBAN/customer number into code, tests, or commits.


Bank Frick WebAPI — the relevant facts (from the public docs)

  • Base URL: https://olb.bankfrick.li/webapi/ — API version v2 (paths are /v2/...). Test/sandbox hosts exist (olbtest, olbsandbox).
  • Auth, stage 1 — JWT: POST /v2/authorize with { "key": "<api-key>", "password": "<optional>" } → returns a JWT. Send it on every subsequent call as Authorization: Bearer <jwt>. JWT lifetime is bounded by the API‑key expiry.
  • Auth, stage 2 — request signing: Every request body must be signed with the client's RSA private key and sent as headers:
    • Signature: Base64 of RSA-SHA512(body) (PKCS#1 v1.5; RSA‑PSS is not accepted)
    • algorithm: rsa-sha512 (default; rsa-sha384 / rsa-sha256 also supported)
    • The matching public key is uploaded once in the Bank Frick online‑banking GUI ("SSH‑Schlüssel" / Manage Key). The POST /v2/authorize request itself must already be signed — signing precedes the first token.
  • Endpoints we need:
    • GET /v2/accounts and GET /v2/accounts/{customer}/{account} — accounts & balances
    • GET /v2/accounts/{customer}/{account}/transactions — transaction history (Frick JSON)
    • GET /v2/camt.053 — full statement (ISO‑20022 camt.053 XML), GET /v2/camt.052 — intraday
    • PUT /v2/pain.001 — payment initiation (ISO‑20022 pain.001 XML)
    • PUT /v2/transactions — create payment orders (SEPA / SEPA‑Instant / FOREIGN / QR‑Bill); GET/DELETE variants
    • POST /v2/requestTan + POST /v2/signTransactionWithTan — TAN challenge & approval for payments; POST /v2/signTransactionWithoutTan exists but requires backend enablement
    • …/instant-transaction-notification-rules — webhook rules (optional, Phase 3)
  • Constraints: optional IP whitelist on the API token; TAN release is required to approve payments unless the token/beneficiary is backend‑exempted; prepared transactions auto‑expire after 7 days. Transaction lifecycle: PREPARED → IN_PROGRESS → EXECUTED → BOOKED (or REJECTED/EXPIRED/DELETED).

How DFX models banks today (orientation)

There is no generic multi‑bank dispatcher — each bank is wired individually. Three existing patterns:

Bank Auth Inbound (statements) Outbound (payout)
Olkypay OAuth2 token in header, refresh on 403 Poll via cron + lastBankOlkyDate watermark proprietary order/vir, two‑step approval (TO_VALIDATE → status poll)
Yapeal static x-api-key + mTLS client cert Webhook (camt.054 → RxJS Subject → handler) REST pain.001 (JSON), no TAN
Raiffeisen EBICS (keys AES‑encrypted in DB) camt via EBICS (client exists) pain.001 XML via EBICS — dead code, never called
Maerki Baumann SEPA file upload (POST /bankTx) manual/out‑of‑band file upload, only detected via a log entry

Reusable building blocks (do not rebuild):

  • Iso20022Service (src/integration/bank/services/iso20022.service.ts) — static, transport‑agnostic:
    • parseCamt053Xml(xml, accountIban): CamtTransaction[] (also used for camt.052)
    • createPain001Xml(payment: Pain001Payment): string (pain.001.001.03, SEPA) and createPain001Json(...)
  • Util.createSign(data, key, algo='sha256', encoding='base64') (src/shared/utils/util.ts:457) — wraps Node crypto.createSign; call it with 'sha512' for Frick. Util.verifySign is the counterpart. Reference usage: payment-webhook.service.ts (body → sign → header).
  • HttpService (src/shared/services/http.service.ts) — axios wrapper with retry + LOC mock mode.
  • BankService (src/subdomains/supporting/bank/bank/bank.service.ts) — getBank({currency,paymentMethod}), getSenderBank(currency), getBankByIban(iban), getBankInternal(name,currency).
  • BankTxService.create() (bank-tx.service.ts:299) — dedup on accountServiceRef (ConflictException swallowed by callers = "already imported").

Closest template for the new client: Olkypay (src/integration/bank/services/olkypay.service.ts) — token‑in‑header + refresh maps cleanly onto Frick's JWT lifecycle, and its poll‑with‑watermark inbound is the safest fit. Use Yapeal (yapeal.service.ts) as the template for the REST payout method shape, but do not copy Yapeal's auth (static key + mTLS) — Frick uses JWT + body signature, no client cert.


Design decision: reuse the Olkypay poll pattern for inbound

Reasons: Frick's JWT‑bearer lifecycle mirrors Olkypay's token‑in‑header + refresh; Frick's statement read is a GET camt.053 pull (same spirit as Olkypay reporting/ecritures and Raiffeisen EBICS C53); it plugs straight into the existing, idempotent checkBankTx cron with a lastBankFrickDate watermark and create() dedup, without exposing a new inbound webhook surface; and polling is self‑healing (a missed tick recovers on the next run — a missed webhook is lost forever), which matches our fail‑closed backend doctrine. Frick's instant-transaction-notification-rules webhook, if used at all, should only be a latency trigger that kicks the same poll — never the source of truth (Phase 3, optional).


Scope (phased)

Ship behind a DisabledProcess kill‑switch and default the payout rail off until credentials + reconciliation are verified in production (same as the Yapeal/Olky rollout).

Phase 0 — Registry & configuration

  • Add FRICK = 'Bank Frick' to IbanBankName (src/subdomains/supporting/bank/bank/dto/bank.dto.ts:17).
    • ⚠️ Beware name collisions: 'Bank Frick (SIC/SEPA) International' already exists as Kraken correspondence‑bank method labels in log-job.service.ts, and sift.dto.ts has FRICK = '$frick'. Those are unrelated to our house bank — do not reuse or overwrite them. Decide the enum value with the team ('Bank Frick' vs. a suffixed value, see the CT/Operating decision below).
  • Add a frick block to Config.bank (src/config/config.ts:1181, next to olkypay/raiffeisen/yapeal), e.g.:
    frick: {
      baseUrl: process.env.FRICK_BASE_URL,          // https://olb.bankfrick.li/webapi/v2
      apiKey: process.env.FRICK_API_KEY,
      privateKey: process.env.FRICK_PRIVATE_KEY?.split('<br>').join('\n'), // PEM, <br>-encoded (same convention as YAPEAL_CERT, config.ts:1213)
      customer: process.env.FRICK_CUSTOMER,         // customer number (provided by DFX team)
    },
    Do not add ?? default fallbacks — fail loud when unconfigured (see Gotchas).
  • Add FRICK_BASE_URL, FRICK_API_KEY, FRICK_PRIVATE_KEY, FRICK_CUSTOMER to .env.example (PR‑checklist item 2). Leave values blank.
  • Bank rows. Add the Frick account rows to migration/seed/bank.csv (loc seed) and as a hand‑written data migration (INSERT INTO "bank" ...) because prod DBs are not re‑seeded (seed.js is loc‑only; no migration/enum inserts bank rows today). Fields per row: name=<Frick enum>, bic='BFRILI22', currency='CHF'|'EUR', iban=<provided by DFX team>, receive, send, sctInst, amlEnabled=true. Use placeholders / the DFX‑team‑provided values — do not commit real IBANs if the repo policy is to keep them out; confirm with the team.
  • (Optional, only if Frick balances must feed totalBalanceChf / the financial log) add Blockchain.BANK_FRICK (src/integration/blockchain/shared/enums/blockchain.enum.ts), extend blockchainToBankName() in bank.service.ts, create CHF/EUR liquidity assets, and add a case IbanBankName.FRICK in the balance adapter bank.adapter.ts. Without this, Frick balance falls into the default branch (derived from the last bank_tx_batch).

Phase 1 — Inbound: statement import (deposits)

  • New src/integration/bank/services/frick.service.ts@Injectable, DfxLogger, injects HttpService. Mirror Olkypay's structure. Sketch:
    @Injectable()
    export class BankFrickService {
      private readonly logger = new DfxLogger(BankFrickService);
      private accessToken?: string;
      private tokenExpiry?: Date;
    
      constructor(private readonly http: HttpService) {}
    
      isAvailable(): boolean { /* !!Config.bank.frick.apiKey && !!Config.bank.frick.privateKey — fail loud otherwise */ }
    
      // --- ACCOUNT / BALANCE ---
      async getBalances(): Promise<{ iban: string; currency: string; balance: number }[]>  // GET /v2/accounts
    
      // --- TRANSACTIONS (READ / POLL) ---
      async getFrickTransactions(lastModificationTime: Date, accountIban: string): Promise<Partial<BankTx>[]> {
        // GET /v2/camt.053 (XML) -> Iso20022Service.parseCamt053Xml(xml, accountIban) -> map with parseTransaction()
      }
      private parseTransaction(tx: CamtTransaction, accountIban: string): Partial<BankTx> // template: raiffeisen.service.ts parseTransaction
    
      // --- AUTH + SIGNING ---
      private async callApi<T>(path: string, method: Method, body?: any): Promise<T> {
        // 1. ensure valid JWT (authorize() if missing/expired)
        // 2. serialize body ONCE to the exact string that is sent
        // 3. sign that exact string: Util.createSign(bodyString, Config.bank.frick.privateKey, 'sha512', 'base64')
        // 4. headers: Authorization: Bearer <jwt>, Signature: <sig>, algorithm: 'rsa-sha512'
        // 5. on 401 -> authorize() once and retry (Olkypay 403-retry pattern)
      }
      private async authorize(): Promise<void> // POST /v2/authorize {key: apiKey} — SIGNED — cache token + expiry
    }
    • Add a frick.dto.ts for Frick JSON shapes (auth response, account) if the JSON transactions endpoint is used instead of camt.053 (see open questions).
    • Do not create a recipient entity (Olkypay's OlkyRecipient is Olky‑specific; Frick takes the full creditor inline in the payment body).
  • Register BankFrickService in src/integration/bank/bank.module.ts under providers and exports (one‑module rule).
  • Wire the poll into BankTxService:
    • inject BankFrickService into the constructor (bank-tx.service.ts, next to olkyService);
    • add a checkFrickTransactions() modeled on checkTransactions() (bank-tx.service.ts:171): read watermark lastBankFrickDate (default new Date(0)), resolve the account IBAN via bankService.getBankInternal(IbanBankName.FRICK, 'CHF'|'EUR') (skip‑with‑warn if not configured, like the Olky guard at :181), fetch, loop this.create(tx) (swallow ConflictException), and advance the watermark only when length > 0 (self‑healing, :199);
    • call it from the checkBankTx() cron (bank-tx.service.ts:124, @DfxCron(EVERY_30_SECONDS, { process: Process.BANK_TX })).
    • Iterate over all Frick account IBANs that should receive (loop per account).
  • Imported bank_tx then flow through the existing assignTransactions()updateInternal()fillBankTx() chain unchanged (deposit → buy‑crypto matching). No changes needed there.

Phase 2 — Outbound: fiat payout

Payout is driven by FiatOutputJobService.fillFiatOutput() (src/subdomains/supporting/fiat-output/fiat-output-job.service.ts:59), sequence at :63–67: createBatches → transmitYapealPayments → transmitOlkypayPayments → searchOutgoingBankTx.

  • Add transmitFrickPayments() modeled on transmitYapealPayments() (:381): build a Pain001Payment and call bankFrickService.sendPayment().
    • sendPayment()PUT /v2/pain.001 reusing Iso20022Service.createPain001Xml(payment) or PUT /v2/transactions with a Frick JSON body (decision below). Set debtor.bic = 'BFRILI22'createPain001Xml renders <BIC>undefined</BIC> if it is missing.
  • TAN handling — the key decision. If Frick requires interactive TAN per payment, full automation is impossible and the rail must behave like Olkypay's two‑step model: transmit submits + stores a frickOrderId/frickTxId and sets isTransmittedDate; a separate approval step sets isApprovedDate. There is already a blueprint: checkOlkypayOrderStatus() (:71) polls TO_VALIDATE orders. Map Frick's requestTan/signTransactionWithTan onto the same isTransmittedDate/isApprovedDate/isConfirmedDate fields on FiatOutput. If a technical‑user/whitelisted‑beneficiary exemption or a batch‑wide TAN is available, a fully/near‑automated rail (Yapeal‑style) is possible instead.
  • Migration: add frickTxId (and frickOrderId if two‑step) columns to FiatOutput (fiat-output.entity.ts), analogous to yapealMsgId/olkyOrderId.
  • Routing: exclude FRICK from createBatches() (like YAPEAL/OLKY); include it in the setReadyDate() pending/liquidity filter; extend the EUR routing guard in setReadyDate (today EUR that is not Olky is skipped) so Frick EUR payouts (if any) become ready.
  • Reconciliation is rail‑independent: searchOutgoingBankTx() (:504) matches the outgoing camt bank_tx by remittanceInfo/endToEndId and only then sets isComplete. Ensure the Frick statement feed echoes the endToEndId/remittance so the match succeeds — otherwise the FiatOutput never completes.
  • Guard the whole payout rail behind a DisabledProcess flag (default disabled).

Phase 3 — Instant notifications (optional, later)

  • Register a Frick instant-transaction-notification-rule and add a frick-webhook.controller.ts (@ApiExcludeEndpoint, header‑secret validation like YapealWebhookController) that only triggers the Phase‑1 poll for lower latency. Keep polling as the source of truth.

The signing helper (critical detail)

Frick verifies the signature over the exact bytes it receives. If the string you sign differs from the string axios transmits (whitespace, key order, re‑serialization), every call returns 401.

  • Build the request body as a fixed string, sign that string, and send that string:
    const bodyString = JSON.stringify(payload);
    const signature = Util.createSign(bodyString, Config.bank.frick.privateKey, 'sha512', 'base64');
    // send bodyString as the raw body + headers { Signature: signature, algorithm: 'rsa-sha512' }
  • The POST /v2/authorize body must be signed too (signing happens before the first token).
  • For GET requests (no body), confirm Frick's rule for body‑less requests (empty string vs. no signature) — see open questions.
  • EbicsKeyEncryptor (AES, symmetric) is not usable here — this is asymmetric RSA signing.

Gotchas (pulled from the code)

  • One‑bank‑per‑(name, currency) assumption. getBankInternal keys on `${name}-${currency}` and BankService's static ibanCache keeps the first row on collision. Two Frick CHF rows (CT + Operating) under the same name break selection. The DB unique key is (iban, bic)not (name, currency) — so the DB happily accepts both; the break is purely in code. → Either give CT and Operating distinct enum names, or keep only the customer‑transaction (CT) accounts as bank entities and treat Operating as pure liquidity accounts.
  • name is varchar(256) but code compares against exact enum strings. A free‑text name without the enum entry is ignored by isCountryEnabled, blockchainToBankName, getBankInternal, and the balance‑adapter switch (falls through to default / return true).
  • Prod rows need a data migration. No migration and no code inserts bank rows today; seed.js/bank.csv is loc‑only.
  • JWT is runtime‑derived, not a stored secret — never put it in config.ts; cache in memory with expiry and refresh on 401.
  • PEM <br> decoding is mandatory for FRICK_PRIVATE_KEY (.split('<br>').join('\n')), else crypto cannot parse the key and every signature fails.
  • No silent fallbacks (CONTRIBUTING + repo policy): fail loud (throw / ServiceUnavailableException) when Frick is unconfigured — mirror Yapeal's "not configured" behavior. Keep at least a logger.error on poll failures; don't swallow silently.
  • createPain001Xml is hard‑wired to SvcLvl Cd=SEPA, NbOfTxs=1. FOREIGN / QR‑Bill / LI‑domestic CHF (SIC) may need a different payment type or the PUT /v2/transactions endpoint instead.
  • pain.001 version: the generator emits pain.001.001.03. Verify Frick accepts it (some LI/CH banks require .09).
  • camt.052 shares the camt.053 parser, which hard‑sets status=BOOKED. Intraday pending entries would be mislabeled — use camt.053 for booked statements.
  • LOC mock: HttpService mocks external hosts by regex in the LOC environment — add olb.bankfrick.li to the mock patterns or local calls return mock/empty.
  • LI is domestic (Config.isDomesticIban covers CH/LI), and the new Frick IBANs are auto‑blocked as DFX‑own IBANs (is-dfx-iban.validator.ts) once the rows exist — intended.

Open questions / decisions needed

These affect the shape of the implementation; the DFX team should confirm (some require checking against the Frick test environment):

  1. TAN automatability — is TAN required per payment, or is there a batch‑wide TAN / technical‑user or whitelisted‑beneficiary exemption? Determines auto (Yapeal‑style) vs. submit‑then‑approve (Olky‑style) vs. manual (Maerki‑style) payout rail. Does the RSA request signature already count as the second factor for API‑submitted payments?
  2. Inbound source — read booked statements via GET /v2/camt.053 (reuses parseCamt053Xml) or via the Frick JSON GET /v2/accounts/{customer}/{account}/transactions (needs a frick.dto.ts + custom mapping)? Recommended: camt.053 for reuse.
  3. Outbound formatPUT /v2/pain.001 (reuse createPain001Xml) or PUT /v2/transactions (Frick JSON, needed for FOREIGN/QR‑Bill)? And which pain.001 version does Frick accept?
  4. Account roles / flags — which IBAN is receive (customer deposits) vs. send (payout)? Does Frick support SEPA‑Instant / SIC‑Instant (sctInst)? There are 4 accounts: CHF/EUR × customer‑transaction (CT) + operating.
  5. CT vs. Operating naming — one name with per‑currency rows, or distinct names per account role (resolves the (name,currency) collision)?
  6. Currency ownership — does Frick handle CHF, EUR, or both, and does it replace or complement Yapeal (CHF) / Olkypay (EUR)? Determines getSenderBank priority when multiple send=true banks exist per currency.
  7. Liquidity balance — must Frick balances feed totalBalanceChf / financial log (→ new Blockchain.BANK_FRICK, liquidity assets, balance‑adapter case)?
  8. GET signing rule — how does Frick expect body‑less (GET) requests to be signed?
  9. Reconciliation channel — does the outgoing statement feed reliably echo endToEndId/remittance for searchOutgoingBankTx to match?

What the DFX team provides (not part of this issue)

Handled privately by the DFX team; the implementer only wires the env‑var names:

  • Env values (set in the deployment environment): FRICK_API_KEY, FRICK_PRIVATE_KEY (PEM, <br>‑encoded), FRICK_BASE_URL, FRICK_CUSTOMER.
  • Key registration: the matching RSA public key uploaded in the Bank Frick OLB; the API access token created (with IP whitelist for the API host and TAN‑release configuration).
  • Bank rows in prod: the concrete IBANs, customer/contact numbers inserted via the data migration (values supplied to the implementer or applied by DFX directly).

Acceptance criteria

  • Build gates pass: npm run format, npm run lint, npm run type-check, npm test.
  • 4‑point PR checklist satisfied: migration (bank rows + FiatOutput columns), env/infra (.env.example + config), service updates, frontend sync if any API contract changes.
  • Phase 1: a Frick camt.053 statement (test env / fixture) imports into bank_tx with correct accountIban, dedup on accountServiceRef, watermark advancing only on non‑empty fetch.
  • Phase 2: a payout produces a signed, authorized Frick request; the TAN/approval flow matches the chosen model; searchOutgoingBankTx reconciles it to isComplete. Rail behind a default‑off DisabledProcess flag.
  • Unit tests for the signing helper (exact‑body signature) and the camt.053 → bank_tx mapping.
  • No silent fallbacks; secrets never logged or returned in DTOs.

Reference: files to mirror / touch

File Why
src/integration/bank/services/olkypay.service.ts Primary template for BankFrickService (token auth + refresh, poll, parseTransaction)
src/integration/bank/services/yapeal.service.ts Template for the REST payout method shape (sendPayment via pain.001) — not the auth
src/integration/bank/services/iso20022.service.ts Reuse parseCamt053Xml, createPain001Xml, CamtTransaction/Pain001Payment
src/integration/bank/services/raiffeisen.service.ts parseTransaction(CamtTransaction, accountIban): Partial<BankTx> mapping template
src/shared/utils/util.ts:457 Util.createSign — RSA‑SHA512 signature primitive
src/subdomains/core/payment-link/services/payment-webhook.service.ts Reference: body → sign → header
src/subdomains/supporting/bank/bank/dto/bank.dto.ts:17 IbanBankName enum — add FRICK
src/subdomains/supporting/bank/bank/bank.service.ts getBankInternal / getSenderBank / getBankByIban / ibanCache
src/config/config.ts:1181 Config.bank — add frick block; PEM <br> convention at :1213
.env.example add FRICK_* vars
migration/seed/bank.csv + a new data migration Frick bank rows (loc seed + prod insert)
src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:124/171 checkBankTx cron + checkTransactions — add checkFrickTransactions() (lastBankFrickDate)
src/subdomains/supporting/bank-tx/bank-tx/services/bank-transaction-handler.service.ts Webhook consumer pattern (Phase 3 only)
src/subdomains/supporting/fiat-output/fiat-output-job.service.ts:63/381/444/71/504 fillFiatOutput chain — add transmitFrickPayments(); TAN model = checkOlkypayOrderStatus
src/subdomains/supporting/fiat-output/fiat-output.entity.ts add frickTxId/frickOrderId
src/integration/bank/bank.module.ts register BankFrickService (providers + exports)
src/integration/bank/controllers/yapeal-webhook.controller.ts template for optional Frick webhook (Phase 3)

Public API docs: https://developers.bankfrick.li/api_docs/#introduction

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions