vs-point / kb-adaa
A PHP library for communication with the Komerční banka Account Direct Access (ADAA) API v2.
Requires
- php: >=8.2
- ext-json: *
- ext-openssl: *
- brick/date-time: ^0.7.0
- brick/math: ^0.12.0
- brick/money: ^0.10.0
- guzzlehttp/guzzle: ^7.9
- phpdocumentor/reflection-docblock: ^5.4
- psr/http-message: ^1.1|^2.0
- ramsey/uuid: ^4.7
- symfony/property-access: ^6.4|^7.0|^8.0
- symfony/property-info: ^6.4|^7.0|^8.0
- symfony/serializer: ^6.4|^7.0|^8.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- symplify/easy-coding-standard: ^12.0
README
PHP knihovna pro komunikaci s Komerční banka Account Direct Access API v2. Typovaný interface pro účty, zůstatky, transakce, výpisy v PDF a notifikace o pohybech.
Pokrývá všech 5 vrstev integrace:
| Vrstva | Co dělá | Třída |
|---|---|---|
| Runtime ADAA | čte účty, zůstatky, transakce, výpisy, spravuje notifikační subscriptions | KbAdaaClient->accounts, ->balances, ->transactions, ->statements, ->eventSubscriptions |
| OAuth2 | vyměňuje authorization code za tokeny, refreshuje access token, sestavuje login URL | KbAdaaClient->oauth2 |
| Client Registration (mTLS) | žádá KB o software statement JWT pro tvou aplikaci | KbAdaaClient->clientRegistration |
| Application Registration | builduje redirect URL a dešifruje AES-256-GCM odpověď z callbacku | KbAdaaClient->applicationRegistration |
| Event API receiver | server-side helpery pro implementaci tvého webhook endpointu | KbAdaaClient->eventApi |
Instalace
composer require vs-point/kb-adaa
Vyžaduje PHP ≥ 8.2 a rozšíření openssl, json, curl (curl ≥ 7.71 pro inline certifikát).
Architektura konfigurace
ADAA má tři různé apiKey (Client Registration, OAuth2, ADAA) a každý se získává zvlášť v developer portálu. Proto má knihovna tři oddělené config objekty:
use VsPoint\KbAdaa\Config\KbAdaaConfig; // pro runtime ADAA volání use VsPoint\KbAdaa\Config\OAuth2Config; // pro token endpoint use VsPoint\KbAdaa\Config\ClientRegistrationConfig; // pro software statement (mTLS)
Při inicializaci KbAdaaClient jsou OAuth2Config a ClientRegistrationConfig volitelné — předáš jen ty, které potřebuješ pro aktuální use-case.
Sandbox vs produkce
use VsPoint\KbAdaa\Enum\Environment; Environment::Sandbox // https://api-gateway.kb.cz/sandbox/... Environment::Production // https://api-gateway.kb.cz/... (Client Registration má vlastní host)
Token storage
Access token žije pouze 3 minuty, refresh token 12 měsíců. Knihovna vyžaduje TokenStorageInterface:
interface TokenStorageInterface { public function load(): ?AccessToken; public function save(AccessToken $token): void; public function clear(): void; }
Pro lokální vývoj / CLI použij InMemoryTokenStorage. Pro produkci napiš vlastní implementaci nad svou DB / Redisem / secret managerem.
Auto-refresh: Pokud klientovi předáš OAuth2Config, automaticky před každým voláním zkontroluje expiraci tokenu, sám refreshne a uloží nový. Pokud OAuth2Config nepředáš, používá se uložený access token jak je (chování pro „manuální“ režim).
Quick start — runtime ADAA
Předpokládá, že už máš access token + refresh token získaný OAuth2 flow (viz dál).
use VsPoint\KbAdaa\Config\KbAdaaConfig; use VsPoint\KbAdaa\Config\OAuth2Config; use VsPoint\KbAdaa\Enum\Environment; use VsPoint\KbAdaa\KbAdaaClient; use VsPoint\KbAdaa\Token\AccessToken; use VsPoint\KbAdaa\Token\InMemoryTokenStorage; $tokenStorage = new InMemoryTokenStorage(new AccessToken( accessToken: 'eyJ...', refreshToken: 'eyJ...', expiresAt: time() + 180, scope: 'adaa', )); $client = KbAdaaClient::create( config: new KbAdaaConfig( apiKey: 'adaa-api-key', environment: Environment::Production, ), tokenStorage: $tokenStorage, // Volitelné — povoluje auto-refresh tokenu: oauth2Config: new OAuth2Config( apiKey: 'oauth2-api-key', clientId: 'YourApp-1234', clientSecret: 'shh', redirectUri: 'https://nas-produkt.cz/kb/callback', // KB ho vyžaduje i u refresh environment: Environment::Production, ), ); // Účty foreach ($client->accounts->list() as $account) { echo $account->iban . ' (' . $account->currency?->getCurrencyCode() . ')' . PHP_EOL; }
Konfigurace mTLS (Client Registration)
Software statement endpoint vyžaduje kvalifikovaný certifikát od I.CA nebo PostSignum (KB ho sama nevydává). Certifikační autority ho typicky dávají jako .p12 (PKCS#12). Knihovna nabízí tři cesty, vyber si podle toho, kde certifikát žije:
| Třída | Vstup | Kdy použít |
|---|---|---|
ClientRegistrationP12Config |
.p12 soubor nebo bytes + heslo |
Máš .p12 přímo od I.CA / PostSignum, nechceš konvertovat |
ClientRegistrationConfig |
cesta k .pem souboru |
Po jednorázové konverzi P12→PEM, certifikát žije na disku |
ClientRegistrationInlineConfig |
PEM string | Certifikát ze secret manageru / env proměnné |
Z .p12 souboru (přímo od certifikační autority)
Nejjednodušší — knihovna provede openssl_pkcs12_read() interně:
use VsPoint\KbAdaa\Config\ClientRegistrationP12Config; $config = ClientRegistrationP12Config::fromFile( apiKey: 'cr-api-key', p12Path: '/secrets/qualified-cert.p12', p12Password: 'heslo-k-p12', environment: Environment::Production, );
Pokud máš .p12 už načtený v paměti (typicky ze secret manageru):
$config = new ClientRegistrationP12Config( apiKey: 'cr-api-key', p12Content: $secretManager->get('kb-qualified-cert.p12'), p12Password: $secretManager->get('kb-qualified-cert-password'), environment: Environment::Production, );
OpenSSL 3.x heads-up:
openssl_pkcs12_read()selže na.p12šifrovaných legacy algoritmy (RC2 / 3DES) — časté u starších Windows exportů z I.CA. Pokud konstruktor vyhodí výjimku, proveď jednorázovou konverziopenssl pkcs12 -legacy ...a použijClientRegistrationConfigneboClientRegistrationInlineConfig.
Z PEM souboru na disku
use VsPoint\KbAdaa\Config\ClientRegistrationConfig; $config = new ClientRegistrationConfig( apiKey: 'cr-api-key', certPath: '/secrets/qualified-cert.pem', certPassword: null, // pokud je PEM zašifrovaný keyPath: null, // pokud je klíč v jiném souboru keyPassword: null, environment: Environment::Production, );
Konverze .p12 → .pem:
openssl pkcs12 -in qualified-cert.p12 -out qualified-cert.pem -nodes -passin pass:HESLO
Inline (PEM string ze secret manageru)
Žádný dočasný soubor se nezapisuje — používá CURLOPT_SSLCERT_BLOB (curl 7.71+).
use VsPoint\KbAdaa\Config\ClientRegistrationInlineConfig; $config = new ClientRegistrationInlineConfig( apiKey: 'cr-api-key', certPem: $_ENV['KB_QUALIFIED_CERT_PEM'], keyPem: $_ENV['KB_QUALIFIED_KEY_PEM'], // pokud klíč není v certPem environment: Environment::Production, );
Kompletní onboarding flow
KB onboarding je jednorázový proces na začátku integrace. Pak už jen průběžně refreshuješ token a voláš ADAA.
1. Software statement (jednou za 12 měsíců na aplikaci)
use VsPoint\KbAdaa\DTO\ClientRegistration\SoftwareStatementPayload; $statement = $client->clientRegistration->issue(new SoftwareStatementPayload( softwareName: 'Náš produkt', softwareNameEn: 'Our product', softwareId: bin2hex(random_bytes(16)), softwareVersion: '1.0', softwareUri: 'https://nas-produkt.cz', redirectUris: ['https://nas-produkt.cz/kb/callback'], registrationBackUri: 'https://nas-produkt.cz/kb/registration-back', contacts: ['email: support@nas-produkt.cz'], )); echo $statement->token; // raw JWT echo $statement->claims->vendorName; // dekódované claims echo $statement->claims->exp; // expirace (12 měsíců) // → ulož $statement->token do secret manageru, použiješ při registraci aplikace
2. Application registration (jednou za 12 měsíců na klienta — provede uživatel přes browser)
use VsPoint\KbAdaa\DTO\ApplicationRegistration\RegistrationRequest; use VsPoint\KbAdaa\Enum\ApplicationType; use VsPoint\KbAdaa\Enum\Scope; // Vygeneruj a ulož AES-256 klíč — budeš ho potřebovat pro dešifrování callbacku. $encryptionKey = $client->applicationRegistration->generateEncryptionKey(); // $encryptionKey ulož do session / DB svázaný s registrací $redirectUrl = $client->applicationRegistration->buildRedirectUrl( Environment::Production, new RegistrationRequest( clientName: 'Náš produkt', clientNameEn: 'Our product', redirectUris: ['https://nas-produkt.cz/kb/callback'], scope: [Scope::Adaa, Scope::CardData], encryptionKey: $encryptionKey, softwareStatement: $statement->token, applicationType: ApplicationType::Web, ), ); // Redirectni uživatele header('Location: ' . $redirectUrl);
Po dokončení se uživatel vrátí na tvůj registrationBackUri s ?salt=…&encryptedData=…. Dešifruj:
$registration = $client->applicationRegistration->decryptResponse( base64Salt: $_GET['salt'], base64EncryptedData: $_GET['encryptedData'], base64EncryptionKey: $encryptionKey, // tentýž klíč jako výše ); echo $registration->clientId; // ulož do DB echo $registration->clientSecret; // ulož do DB
3. OAuth2 — získání access tokenu (uživatel se přihlásí do KB)
$loginUrl = $client->oauth2->buildAuthorizationUrl( scopes: [Scope::Adaa], state: bin2hex(random_bytes(16)), ); header('Location: ' . $loginUrl);
redirect_urise vždy bere zOAuth2Config::$redirectUri— KB ho vyžaduje shodný napříč authorize / token-exchange / refresh.
Callback handler:
$accessToken = $client->oauth2->exchangeAuthorizationCodeForToken(code: $_GET['code']); $tokenStorage->save($accessToken);
Od této chvíle můžeš volat ADAA endpointy. Pokud máš OAuth2Config připojený na klientovi, refresh probíhá automaticky.
Použití runtime API
Účty
$accounts = $client->accounts->list(); foreach ($accounts as $account) { echo $account->accountId . ' — ' . $account->iban . PHP_EOL; } // accountId se nemění — cachuj ho, ať nepáliš zbytečně API call
Zůstatky
foreach ($client->balances->list($accountId) as $balance) { echo $balance->type?->value . ': ' . $balance->amount?->toMoney() . PHP_EOL; } // CLOSING_AVAILABLE: CZK 85,061.41 // PREVIOUSLY_CLOSED_BOOK: CZK 85,061.41
Transakce
use Brick\DateTime\Duration; use Brick\DateTime\TimeZone; use Brick\DateTime\ZonedDateTime; use VsPoint\KbAdaa\DTO\Transaction\TransactionQuery; $now = ZonedDateTime::now(TimeZone::utc()); $page = $client->transactions->list( $accountId, new TransactionQuery( fromDateTime: $now->minusDuration(Duration::ofDays(30)), toDateTime: $now, page: 0, size: 100, // max 100 (KB-enforced) ), ); foreach ($page->content as $tx) { echo $tx->bookingDate . ' ' . $tx->amount?->toMoney() . ' ' . $tx->status?->value . PHP_EOL; // Pro párování PDNG → BOOK použij references.accountServicer echo ' pair key: ' . $tx->references?->accountServicer . PHP_EOL; }
Throughput limit KB: 1 stažení / hodinu. Pro vyšší frekvenci použij Event API subscriptions níže.
Výpisy v PDF
use Brick\DateTime\ZonedDateTime; use VsPoint\KbAdaa\DTO\Statement\StatementListQuery; $statements = $client->statements->list( $accountId, new StatementListQuery(dateFrom: ZonedDateTime::parse('2024-01-01T00:00:00Z')), ); foreach ($statements as $statement) { $pdf = $client->statements->download($accountId, $statement->statementId); file_put_contents("statement-{$statement->statementId}.pdf", $pdf); }
Notifikace (subscription management)
use VsPoint\KbAdaa\DTO\EventSubscription\CreateSubscriptionPayload; $subscription = $client->eventSubscriptions->create( $accountId, new CreateSubscriptionPayload( eventApiUrl: 'https://nas-produkt.cz/webhook/kb-events', eventApiKey: bin2hex(random_bytes(32)), // sdílené tajemství — KB ho posílá jako x-api-key header ), ); echo $subscription->subscriptionId; // ulož do DB ke svému účtu // Později: $client->eventSubscriptions->delete($accountId, $subscription->subscriptionId);
KB volá tvůj endpoint z IP
194.50.202.179a194.50.226.179— povolit ve firewallu.
Event API receiver (serverová strana webhooku)
KB vyžaduje, aby tvůj endpoint implementoval dvě cesty:
POST /subscriptions/{subscriptionId}/events— musí vrátit HTTP 204 do 5 sekundGET /version— musí vrátit{"version": "1.0"}
Knihovna ti dává helper, který je framework-agnostický:
// V tvém controlleru pro POST /subscriptions/{subscriptionId}/events try { $payload = $client->eventApi->handleEvent( jsonBody: file_get_contents('php://input'), expectedApiKey: $mySubscriptionApiKey, // ten, co jsi předal při create() providedApiKey: $request->headers->get('x-api-key', ''), ); // $payload->eventCount — počet nových událostí // Spusť fetch nových transakcí (async, neblokuj odpověď) dispatch_to_queue(new FetchKbTransactions($accountId)); http_response_code(204); } catch (InvalidEventApiKeyException) { http_response_code(401); echo $client->eventApi->errorResponseJson('Invalid API key'); }
// V controlleru pro GET /version header('Content-Type: application/json'); echo $client->eventApi->versionResponseJson();
KB při 401/403/404 odpovědi subscription trvale zastaví (status STOPPED). Při 500 nebo timeout circuit breaker (SUSPENDED), opakování → STOPPED. Nereaguj 5xx kvůli své interní chybě — vrať 204 a process asynchronně.
Symfony DI integrace
Knihovna sama není Symfony bundle (žádný DI extension). Stačí YAML wiring:
# config/services.yaml services: VsPoint\KbAdaa\Config\KbAdaaConfig: arguments: $apiKey: '%env(KB_ADAA_API_KEY)%' $environment: !php/enum VsPoint\KbAdaa\Enum\Environment::Production VsPoint\KbAdaa\Config\OAuth2Config: arguments: $apiKey: '%env(KB_OAUTH2_API_KEY)%' $clientId: '%env(KB_OAUTH2_CLIENT_ID)%' $clientSecret: '%env(KB_OAUTH2_CLIENT_SECRET)%' $redirectUri: '%env(KB_OAUTH2_REDIRECT_URI)%' $environment: !php/enum VsPoint\KbAdaa\Enum\Environment::Production VsPoint\KbAdaa\Config\ClientRegistrationConfig: arguments: $apiKey: '%env(KB_CR_API_KEY)%' $certPath: '%env(KB_CR_CERT_PATH)%' $environment: !php/enum VsPoint\KbAdaa\Enum\Environment::Production # Implementuj vlastní TokenStorage nad svou DB / Redisem VsPoint\KbAdaa\Token\TokenStorageInterface: class: App\Kb\DoctrineTokenStorage VsPoint\KbAdaa\KbAdaaClient: factory: ['VsPoint\KbAdaa\KbAdaaClient', 'create'] arguments: $config: '@VsPoint\KbAdaa\Config\KbAdaaConfig' $tokenStorage: '@VsPoint\KbAdaa\Token\TokenStorageInterface' $oauth2Config: '@VsPoint\KbAdaa\Config\OAuth2Config' $clientRegistrationConfig: '@VsPoint\KbAdaa\Config\ClientRegistrationConfig'
Výjimky
Všechny API výjimky dědí z KbAdaaApiException. getHttpStatus(), getResponseBody(), getErrorCode() (parsovaný kód z {"errors":[{"code":"…"}]}).
| HTTP / důvod | Třída | Typické error codes |
|---|---|---|
| 400 | InvalidRequestException |
DATE_IN_FUTURE, INVALID_DATE_INTERVAL, INVALID_ACCOUNT_ID, INVALID_CORRELATION_ID, INVALID_CURRENCY |
| 401 | UnauthorisedException |
INVALID_TOKEN, 900900 |
| 403 | ForbiddenException |
ACCOUNT_ACCESS_DENIED, ACCOUNT_NOT_CONFIGURED, 900908 |
| 404 | NotFoundException |
ACCOUNT_NOT_FOUND, STATEMENT_NOT_FOUND, SUBSCRIPTION_NOT_FOUND |
| 429 | RateLimitException |
REQUEST_IS_THROTTLED (1 stažení/hod limit), getRetryAfterSeconds() |
| (refresh) | TokenRefreshFailedException |
Refresh token expiroval → uživatel musí re-authorize |
| (callback) | ApplicationRegistrationDecryptionException |
Špatný klíč / poškozený ciphertext |
| (webhook) | InvalidEventApiKeyException |
x-api-key z KB neodpovídá uložené hodnotě |
use VsPoint\KbAdaa\Exception\KbAdaaApiException; use VsPoint\KbAdaa\Exception\NotFoundException; use VsPoint\KbAdaa\Exception\RateLimitException; try { $balances = $client->balances->list($accountId); } catch (NotFoundException $e) { // accountId neexistuje } catch (RateLimitException $e) { sleep($e->getRetryAfterSeconds() ?? 60); } catch (KbAdaaApiException $e) { error_log($e->getHttpStatus() . ': ' . $e->getErrorCode() . ' — ' . $e->getResponseBody()); }
Přehled endpointů
| Service | Metoda | HTTP | Path |
|---|---|---|---|
accounts |
list() |
GET | /adaa/v2/accounts |
balances |
list(accountId) |
GET | /adaa/v2/accounts/{accountId}/balances |
transactions |
list(accountId, ?TransactionQuery) |
GET | /adaa/v2/accounts/{accountId}/transactions |
statements |
list(accountId, StatementListQuery) |
GET | /adaa/v2/accounts/{accountId}/statements |
statements |
download(accountId, statementId) |
GET | /adaa/v2/accounts/{accountId}/statements/{statementId} |
eventSubscriptions |
create(accountId, CreateSubscriptionPayload) |
POST | /adaa/v2/accounts/{accountId}/transactions/event-subscriptions |
eventSubscriptions |
get(accountId, subscriptionId) |
GET | /adaa/v2/accounts/{accountId}/transactions/event-subscriptions/{id} |
eventSubscriptions |
delete(accountId, subscriptionId) |
DELETE | /adaa/v2/accounts/{accountId}/transactions/event-subscriptions/{id} |
oauth2 |
buildAuthorizationUrl(scopes, ?state) |
— (browser redirect) | login.kb.cz nebo sandbox UI |
oauth2 |
exchangeAuthorizationCode(code) |
POST | /oauth2/v3/access_token |
oauth2 |
refresh(refreshToken) |
POST | /oauth2/v3/access_token |
clientRegistration |
issue(SoftwareStatementPayload) |
POST | /client-registration/v3/software-statements |
applicationRegistration |
buildRedirectUrl(env, RegistrationRequest) |
— (browser redirect) | client-registration-ui |
applicationRegistration |
decryptResponse(salt, encrypted, key) |
— (lokální AES-GCM dešifrování) | — |
eventApi |
handleEvent(body, expected, provided) |
— (server-side helper) | — |
Headless bootstrap v sandboxu
Sandbox OAuth2 obrazovka autorizační kód generuje čistě v JavaScriptu — žádné přihlašování, žádný backend roundtrip. Algoritmus je base64(JSON({userId, scopes})) bez = paddingu. Knihovna ho reprodukuje v PHP, takže integrační testy nemusí klikat v prohlížeči.
use VsPoint\KbAdaa\Enum\Scope; use VsPoint\KbAdaa\Sandbox\SandboxAuthorizationCodeFactory; $code = (new SandboxAuthorizationCodeFactory())->generate( userId: 'test', // libovolný neprázdný string — sandbox nehlídá scopes: [Scope::Adaa, Scope::CardData], ); $token = $client->oauth2->exchangeAuthorizationCodeForToken(code: $code); $tokenStorage->save($token); // Hotovo — můžeš volat $client->accounts->list() atd.
Pro plný integrační test stačí jen dva apiKey z developer portálu (KB_ADAA_API_KEY + KB_ADAA_OAUTH2_API_KEY); client_id a client_secret jsou KB-dokumentované sandbox defaults (ExampleClient-6303 / bUfDQ1fMmfaSlZBZXlxBOQ). Viz tests/Integration/SandboxBootstrapTest.php jako referenční E2E příklad — udělá code → token → accounts->list() proti reálnému KB sandboxu bez jakékoliv ruční interakce.
Pouze sandbox. Produkční token endpoint tento kód odmítne. V produkci musí uživatel projít opravdovým KB loginem na
https://login.kb.cz/autfe/ssologin— vizOAuth2Service::buildAuthorizationUrl().
Canary test sandboxového JS
Sandbox algoritmus je závislý na tom, že ho KB nezmění. Unit test testSandboxJavascriptStillImplementsKnownAlgorithm stáhne živý JS a ověří přítomnost klíčových tokenů. Defaultně se skipuje; aktivuj přes:
docker run --rm -v "$(pwd):/app" -w /app -e KB_ADAA_SANDBOX_CANARY=1 vspoint/php:8.5-fpm-alpine sh -c "./vendor/bin/phpunit --testsuite Unit --filter Sandbox"
Pokud canary spadne, znamená to, že KB algoritmus změnili a je třeba aktualizovat SandboxAuthorizationCodeFactory.
Vývoj
composer není na hostu — všechno přes Docker:
# Instalace docker run --rm -v "$(pwd):/app" -w /app vspoint/php:8.5-fpm-alpine sh -c "composer install --no-interaction --prefer-dist" # PHPStan + ECS + Unit testy (musí všechno projít před commitem) docker run --rm -v "$(pwd):/app" -w /app vspoint/php:8.5-fpm-alpine sh -c "./vendor/bin/ecs check --fix && ./vendor/bin/phpstan analyse --memory-limit=512M && ./vendor/bin/phpunit --testsuite Unit --testdox" # Integrační testy proti reálnému KB sandboxu (vyžadují apiKeys — viz .secrets/README.md) ./bin/test-integration.sh ./bin/test-integration.sh --filter SandboxBootstrap # filtr argy projdou skrz na phpunit
Integrační testy
Helper bin/test-integration.sh načte sandbox apiKeys z .secrets/, exportuje je jako env proměnné a spustí integration testy uvnitř Dockeru. Stačí mít dva JWT soubory:
.secrets/
├── sandbox-adaa-apikey.jwt # KB_ADAA_API_KEY
└── sandbox-oauth2-apikey.jwt # KB_ADAA_OAUTH2_API_KEY
Plný návod (kde získat klíče, jaký mít obsah) je v .secrets/README.md. Pozor — .secrets/ je gitignored, klíče tam neházej do commitů.
V GitLab CI je k dispozici stage integration, který se aktivuje automaticky, pokud jsou v project Settings → CI/CD → Variables nastavené (Masked + Protected) proměnné KB_ADAA_API_KEY a KB_ADAA_OAUTH2_API_KEY. Forky / unprotected branches stage tiše přeskočí.
Pokud chceš testovat s předem získaným access/refresh tokenem (bez sandbox bootstrap), nastav místo KB_ADAA_OAUTH2_API_KEY proměnné KB_ADAA_ACCESS_TOKEN + KB_ADAA_REFRESH_TOKEN (volitelně + KB_ADAA_ACCESS_TOKEN_EXPIRES_AT, KB_ADAA_SCOPE, KB_ADAA_ACCOUNT_ID).
Kompatibilita
- PHP ≥ 8.2
- Symfony Serializer / PropertyAccess / PropertyInfo 6.4, 7.x, 8.x
- Bez závislosti na Symfony FrameworkBundle / DI containeru
Licence
MIT