Skip to content

pulse-banking

Audience

PLANA staff. The PSD2 / open-banking layer between Bulgarian banks and each tenant's accounting workflow.

pulse-banking is the unified PSD2 (Berlin Group) connector for every bank we support. It handles consent flows, fetches accounts and transactions, and exposes a stable PLANA-internal API regardless of which bank is being read.

Stack

PropertyValue
Repogit.planapulse.com/plana-pulse/pulse-banking
LanguageJavaScript ESM
FrameworkFastify v5
PSD2 frameworkBerlin Group NextGenPSD2 v1.3.6
Image basenode:20-alpine
Namespacepulse-banking
Port3200
Databasepulse_banking on pg01
CacheRedis at redis.redis:6379

Supported banks

Ten adapters, each implementing the same internal interface:

BankAdapter IDPSD2 endpoint
Revolutrevoluthttps://b2b.revolut.com/api/1.0
UniCredit Bulbankunicredithttps://psd2-prod.unicreditbulbank.bg/v1
United Bulgarian Bank (UBB)ubbhttps://psd2.ubb.bg/api/v1
DSK Bankdskhttps://psd2.dskbank.bg/v1
Postbankpostbankhttps://psd2.postbank.bg/v1
Raiffeisenbankraiffeisenhttps://psd2.raiffeisen.bg/v1
First Investment Bank (FIBank)fibankhttps://api.fibank.bg/psd2/v1
Central Cooperative Bank (CCB)ccbhttps://psd2.ccbank.bg/v1
Bulgarian-American Credit Bank (BACB)bacbhttps://psd2.bacb.bg/v1
MockmockInternal — for testing

All adapters share a common shape:

adapters/
  base.js               # Berlin Group base implementation
  revolut.js            # bank-specific overrides
  unicredit.js
  ubb.js
  dsk.js
  postbank.js
  raiffeisen.js
  fibank.js
  ccb.js
  bacb.js
  mock.js               # for tests and demos

A new bank is added by writing a new adapter file plus any per-bank quirks the upstream PSD2 implementation has — different consent renewal cadences are common.

API surface

GET  /banking/adapters                          list available adapters
POST /banking/:bankId/consent                   start a consent flow → returns URL + consentId
GET  /banking/:bankId/consent/:consentId        check consent status (valid / pending / expired)
GET  /banking/:bankId/accounts                  list accounts under the active consent
GET  /banking/:bankId/accounts/:accountId/transactions?from=…&to=…
GET  /banking/:bankId/accounts/:accountId/balance
GET  /healthz                                   liveness
GET  /readyz                                    readiness (Redis + DB)

Authentication: X-API-Key header per workspace. Calls are scoped to the workspace via the key lookup; a workspace cannot list accounts under another workspace's consent.

The Berlin Group consent flow is the entry point to every bank:

1. Customer in BOS → Banking → "Connect Revolut" → BOS calls:
   POST /api/banking/revolut/consent (proxied through pulse-account-api)

2. pulse-banking returns: { consentId, authUrl, expiresIn }

3. BOS redirects the customer's browser to authUrl
   (the bank's PSD2 SCA consent page)

4. Customer authenticates with their bank, approves the scope
   (typically: read accounts + read transactions, 90-day TTL)

5. Bank redirects back to: https://my.planapulse.ai/banking/callback?
                            consentId=…&bankId=revolut&status=valid

6. pulse-account-api hits pulse-banking GET /banking/revolut/consent/:id
   to confirm; updates the workspace's banking state to "connected"

7. BOS shows the accounts list, fetched via:
   GET /api/banking/revolut/accounts

Consents are bank-specific in detail (some require SCA every 90 days, some every 180; some allow consent renewal, some require a full re-auth). The adapter encapsulates these differences.

Storage

Table (pulse_banking DB)Purpose
consentOne row per workspace × bank — consent_id, status, expires_at, scope JSON
accountCached account metadata under a consent
transactionCached transactions (deduplicated, indexed by transaction_id)

The transaction cache is a write-through cache: pulses pulled from the bank are immediately persisted, then served from the local table on subsequent reads. The cache TTL is 15 minutes for balances and 24 hours for historical transactions. Force-refresh is exposed via the ?refresh=true query parameter.

Configuration

Env varPurpose
DB_URLPostgres connection
REDIS_URLRedis connection
BANK_CREDENTIALS_REVOLUT_CLIENT_ID (etc.)One per bank — credentials live in SOPS
BANK_CREDENTIALS_REVOLUT_CLIENT_SECRET(etc.)
PUBLIC_CALLBACK_URLhttps://my.planapulse.ai/banking/callback

Per-bank credentials are issued by each bank's PSD2 developer portal. PLANA holds one TPP (Third-Party Provider) certificate per bank for the mTLS handshake.

TPP certificates

PSD2 requires TPP authentication via QWAC (Qualified Website Authentication Certificate) and QSEALC (Qualified Electronic Seal Certificate). PLANA holds:

  • One QWAC issued by a Bulgarian QTSP (qualified trust service provider)
  • One QSEALC for request signing

Both certs are mounted into the pulse-banking pod from a SOPS-managed Secret. Renewal is annual; the procedure is in infra/docs/runbooks/tpp-cert-renewal.md.

Common operational tasks

bash
kubectl -n pulse-banking exec deploy/pulse-banking -- \
  psql "$DB_URL" -c "SELECT bank_id, status, expires_at FROM consent WHERE workspace_id='<workspace>' ORDER BY expires_at DESC"

Force-refresh a workspace's account list

bash
curl -sX GET "https://my.planapulse.ai/api/banking/revolut/accounts?refresh=true" \
  -H "Authorization: Bearer <workspace-pa_live-key>"

Diagnose a bank-side outage

If a bank's PSD2 endpoint is down, expect 502 / 504 responses from the adapter. The bank-status dashboard in Grafana tracks each adapter's last successful call.

A multi-hour bank outage is communicated to customers via their workspace Matrix room — banks rarely give SLA notifications, so we detect from our own probes.

Rotate a bank's PSD2 credentials

  1. Bank's PSD2 portal → generate new client_id / secret
  2. Update SOPS pulse_banking.bank_<bankid>_client_secret
  3. Reconcile the secret into the namespace
  4. Restart the deployment

Where to read more

© PLANA Digital Ltd.