diff --git a/README.md b/README.md index 5af445c..500ffec 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,323 @@ A client library for communicating with [FinTS servers](https://www.hbci-zka.de/ > **Note:** This is a fork and continuation of the excellent work by [Frederick Gnodtke (Prior99)](https://github.com/Prior99/fints). We are grateful for the solid foundation and comprehensive implementation provided by the original project. +--- + +## ⚠️ FinTS 4.1 / FinTS 4.0 – Experimental Support + +> **Experimental — use with caution in production** + +This library includes an implementation of the **FinTS 4.1 XML-based protocol** (`FinTS4Client`). This support is **experimental** and subject to the following limitations: + +- **FinTS 4.0 is not a widely deployed version.** Most German retail banks still use FinTS 3.0. FinTS 4.1 support is intended for banks and aggregators that have explicitly adopted the XML-based successor format. +- **Protocol negotiation is best-effort.** The implementation will automatically try fallback versions (`4.1 → 4.0 → 3.0`) when a bank rejects the preferred version, but real-world servers may behave unpredictably. +- **TAN flows are partially supported.** The interactive two-step TAN flow (PIN+TAN, chipTAN, pushTAN) is implemented via a `tanCallback`, but edge cases — such as HHD/Flickercode visualisation, multi-challenge flows, or bank-specific challenge formats — may require additional handling. +- **BPD/UPD parsing is partially generic.** The FinTS 4.1 XML structure leaves room for bank-specific element naming. The parser applies fallback strategies, but untested banks may require further mapping. +- **No write operations.** `FinTS4Client` is currently read-only (accounts, balances, statements). Credit transfers and direct debits are only available via the stable `PinTanClient` (FinTS 3.0). +- **TLS certificate requirements.** Banks with private CA certificates or strict TLS policies require a custom Node.js `https.Agent`, which must be passed via `fetchOptions`. See [Custom TLS / HTTPS configuration](#-custom-tls--https-configuration-nodejs-only) below. + +For production use with the widest bank compatibility, use the **`PinTanClient` (FinTS 3.0)** described in the Quick Start section. + +--- + ## 🎯 Improvements in this Fork +- ✅ **Updated Dependencies**: All dependencies updated to their latest stable versions +- ✅ **Modern TypeScript**: TypeScript 5.x with improved type safety +- ✅ **GitHub Actions**: Automated CI/CD pipeline +- ✅ **FinTS 4.1 (Experimental)**: XML-based protocol with interactive TAN, version negotiation, and improved BPD parsing +- ✅ **Active Maintenance**: Regular updates and dependency maintenance +- ✅ **Published as `fints-lib` and `fints-lib-cli`** on npm + +## 📦 Installation + +```bash +npm install fints-lib +# or +yarn add fints-lib +``` + +For the CLI tool: + +```bash +npm install -g fints-lib-cli +# or +yarn global add fints-lib-cli +``` + +### Development Setup + +```bash +# Install dependencies +yarn install + +# Build all packages +yarn build + +# Run tests +yarn test + +# Run linting +yarn lint +``` + +## 🚀 Quick Start + +### FinTS 3.0 (Stable — recommended for production) + +```typescript +import { PinTanClient } from "fints-lib"; + +const client = new PinTanClient({ + url: "https://banking.example.com/fints", // Your bank's FinTS URL + name: "username", + pin: "12345", + blz: "12345678", +}); + +const accounts = await client.accounts(); +console.log(accounts); +``` + +### FinTS 4.1 (Experimental — XML-based) + +```typescript +import { FinTS4Client } from "fints-lib"; + +// ⚠️ Experimental: most banks still use FinTS 3.0 +const client = new FinTS4Client({ + url: "https://banking.example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", +}); + +const accounts = await client.accounts(); +const balance = await client.balance(accounts[0]); +const stmts = await client.camtStatements(accounts[0]); +``` + +### CLI Quick Start + +```bash +npm install -g fints-lib-cli + +fints-lib list-accounts \ + --url https://banking.example.com/fints \ + -n username -p 12345 -b 12345678 +``` + +## 📖 Common Use Cases + +### 1. Check Account Balance + +```typescript +import { PinTanClient } from "fints-lib"; + +const client = new PinTanClient({ + url: process.env.FINTS_URL, + name: process.env.FINTS_USERNAME, + pin: process.env.FINTS_PIN, + blz: process.env.FINTS_BLZ, +}); + +const accounts = await client.accounts(); +const balance = await client.balance(accounts[0]); +console.log(`Balance: ${balance.value.value} ${balance.value.currency}`); +``` + +### 2. Fetch Recent Transactions + +```typescript +import { PinTanClient } from "fints-lib"; + +const client = new PinTanClient({ /* ... */ }); +const accounts = await client.accounts(); + +const endDate = new Date(); +const startDate = new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000); +const statements = await client.statements(accounts[0], startDate, endDate); + +statements.forEach((statement) => { + statement.transactions.forEach((tx) => { + console.log(` ${tx.amount} ${tx.currency} — ${tx.purpose || "N/A"}`); + }); +}); +``` + +### 3. SEPA Credit Transfer (Send Money) + +```typescript +import { PinTanClient, TanRequiredError } from "fints-lib"; + +const client = new PinTanClient({ /* ... */ }); +const accounts = await client.accounts(); + +try { + await client.creditTransfer(accounts[0], { + debtorName: "John Doe", + creditor: { name: "Recipient", iban: "DE44500105175407324931", bic: "INGDDEFFXXX" }, + amount: 50.0, + remittanceInformation: "Invoice #12345", + }); +} catch (error) { + if (error instanceof TanRequiredError) { + const tan = await promptUser(error.challengeText); // your UI + await client.completeCreditTransfer(error.dialog, error.transactionReference, tan, error.creditTransferSubmission); + } +} +``` + +### 4. FinTS 4.1 — Interactive TAN (Experimental) + +> ⚠️ Experimental. Requires a bank that supports FinTS 4.1 XML. + +```typescript +import { FinTS4Client } from "fints-lib"; +import * as readline from "readline"; + +async function promptTan(challenge: { challengeText?: string; transactionReference: string }): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`TAN challenge: ${challenge.challengeText ?? "(no text)"}\nEnter TAN: `, (tan) => { + rl.close(); + resolve(tan.trim()); + }); + }); +} + +const client = new FinTS4Client({ + url: "https://banking.example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", + tanCallback: promptTan, // called automatically when the bank issues a challenge +}); + +const accounts = await client.accounts(); +// If the bank requires a TAN during the request, `promptTan` is invoked automatically. +const statements = await client.camtStatements(accounts[0]); +``` + +## 🔐 Custom TLS / HTTPS Configuration (Node.js only) + +Some banks use certificates signed by a private CA, or require specific TLS settings. Use +`createTlsAgent()` to create a custom `https.Agent` and pass it via `fetchOptions`: + +```typescript +import { FinTS4Client, createTlsAgent } from "fints-lib"; +import fs from "fs"; + +// Use a custom CA certificate (e.g. a bank-specific private CA) +const agent = createTlsAgent({ + ca: fs.readFileSync("/path/to/bank-ca.pem", "utf8"), +}); + +const client = new FinTS4Client({ + url: "https://banking.example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", + fetchOptions: { agent }, +}); +``` + +> **Security note:** Never set `rejectUnauthorized: false` in production. This disables +> certificate verification entirely and exposes you to man-in-the-middle attacks. + +```typescript +// ✅ Development / testing only: +const devAgent = createTlsAgent({ rejectUnauthorized: false }); + +// ❌ Never in production: +// const prodAgent = createTlsAgent({ rejectUnauthorized: false }); +``` + +> **Note:** `createTlsAgent()` and `fetchOptions.agent` are currently only supported by +> `FinTS4Client` (FinTS 4.1). The FinTS 3.0 `PinTanClient` / `HttpConnection` does not +> expose a custom-agent option; if you need custom TLS for a FinTS 3.0 endpoint, use the +> `NegotiatingClient` with `preferredVersion: "4.1"` or configure TLS at the Node.js +> process level (e.g. `NODE_EXTRA_CA_CERTS`). + +## 🔄 HBCI Version Negotiation (FinTS 4.1) + +`FinTS4Client` automatically negotiates the HBCI version with the server: + +1. It starts with your `preferredHbciVersion` (default: `"4.1"`). +2. If the server returns error `9010` (version not supported), it retries with `"4.0"`, then `"3.0"`. +3. After the sync phase, the client adopts the highest version both sides support. + +```typescript +const client = new FinTS4Client({ + // ... + preferredHbciVersion: "4.1", // start with 4.1, fall back automatically +}); +``` + +## Packages + +- [fints-lib](packages/fints) — Core library +- [fints-lib-cli](packages/fints-cli) — Command line interface + +## 🔒 Security + +- **Never log credentials.** The library masks PINs and TANs in debug output, but never log the raw config object. +- **Store credentials securely.** Use environment variables or a secrets manager. +- **Use HTTPS only.** Always use HTTPS URLs for FinTS endpoints. +- **Debug mode.** Be cautious with `debug: true` in production — it logs full request/response XML. + +### Reporting Security Issues + +Report security vulnerabilities privately via GitHub's **Security** tab instead of opening a public issue. + +## 💡 Tips and Troubleshooting + +### Finding Your Bank's FinTS URL + +- [FinTS Institute Database](https://github.com/jhermsmeier/fints-institute-db) + +### Common Issues + +**Authentication Errors:** +- Verify username, PIN, and BLZ. +- Some banks require enabling FinTS/HBCI access in online banking settings. +- Check if your bank requires product registration. + +**TAN Requirements:** +- Use `tanCallback` (FinTS 4.1) or `TanRequiredError` catch (FinTS 3.0) to handle TAN challenges. + +**Timeout Issues:** +```typescript +const client = new PinTanClient({ timeout: 60000, maxRetries: 5 }); +``` + +**FinTS 4.1 not working with my bank:** +- Most German retail banks use FinTS 3.0 — switch to `PinTanClient`. +- Check the bank's FinTS URL: some banks have separate endpoints for v3 and v4. + +## Mentions + +- [Prior99/fints](https://github.com/Prior99/fints) — Original repository by Frederick Gnodtke 🙏 +- [python-fints](https://github.com/raphaelm/python-fints) — Reference implementation +- [Open-Fin-TS-JS-Client](https://github.com/jschyma/open_fints_js_client) — Demo server +- [mt940-js](https://github.com/webschik/mt940-js) — MT940 format parser + +## Resources + +- [API Reference](https://prior99.gitlab.io/fints) +- [Official specification](https://www.hbci-zka.de/spec/3_0.htm) +- [Database of banks with their URLs](https://github.com/jhermsmeier/fints-institute-db) + +## Contributing + +Contributions in the form of well-documented issues or pull requests are welcome. + +## Contributors + +- Frederick Gnodtke (Original author) +- Lars Decker (Fork maintainer) + + This fork includes several enhancements over the original project: - ✅ **Updated Dependencies**: All dependencies updated to their latest stable versions for better security and performance diff --git a/eslint.config.js b/eslint.config.js index 30ec178..0957183 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,9 +58,12 @@ export default [ Buffer: "readonly", process: "readonly", global: "readonly", + globalThis: "readonly", console: "readonly", __dirname: "readonly", __filename: "readonly", + require: "readonly", + module: "readonly", Response: "readonly", fetch: "readonly", AbortController: "readonly", diff --git a/packages/fints-cli/package.json b/packages/fints-cli/package.json index 2f49b86..1a1f447 100644 --- a/packages/fints-cli/package.json +++ b/packages/fints-cli/package.json @@ -1,6 +1,6 @@ { "name": "fints-lib-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "FinTS command line interface.", "keywords": [ "fints", diff --git a/packages/fints/README.md b/packages/fints/README.md index e944015..d4419a6 100644 --- a/packages/fints/README.md +++ b/packages/fints/README.md @@ -7,6 +7,335 @@ A client library for communicating with [FinTS servers](https://www.hbci-zka.de/ > **Note:** This is a fork and continuation of [Prior99/fints](https://github.com/Prior99/fints). Published as `fints-lib` on npm. +--- + +## ⚠️ FinTS 4.1 / FinTS 4.0 – Experimental Support + +> **Experimental — use with caution in production** + +This library ships a `FinTS4Client` that implements the **FinTS 4.1 XML-based protocol**. This support is **experimental**: + +- **Most German retail banks use FinTS 3.0.** FinTS 4.1 is only deployed by a small number of banks and aggregators. If you are unsure which version your bank uses, start with `PinTanClient`. +- **FinTS 4.0 does not exist as a broadly standardised version.** "FinTS 4.x" refers to the XML-based successor family; the only released version of this family is **4.1**. Any "4.0" fallback in the negotiation logic is a defensive measure for non-standard server implementations. +- **TAN support is partial.** Interactive PIN+TAN, chipTAN, and pushTAN challenges are handled via a `tanCallback`, but complex multi-step flows (e.g. HHD Flickercode, QR-TAN) may require additional handling on your side. +- **BPD/UPD parsing applies best-effort heuristics.** Different banks use slightly different element names and nesting structures in their XML responses. The parser tries several fallback paths, but previously unseen banks may require additional mapping. +- **No write operations.** `FinTS4Client` is read-only (accounts, balances, camt statements). Transfers and direct debits are only available in `PinTanClient` (FinTS 3.0). + +--- + +## Installation + +```bash +npm install fints-lib +# or +yarn add fints-lib +``` + +## Quick Start Examples + +### FinTS 3.0 — Stable (recommended) + +```typescript +import { PinTanClient } from "fints-lib"; + +const client = new PinTanClient({ + url: "https://banking.example.com/fints", + name: "username", + pin: "12345", + blz: "12345678", +}); + +const accounts = await client.accounts(); +const balance = await client.balance(accounts[0]); +console.log(`Balance: ${balance.value.value} ${balance.value.currency}`); +``` + +### FinTS 4.1 — Experimental (XML-based) + +```typescript +import { FinTS4Client } from "fints-lib"; + +// ⚠️ Experimental. Most banks still use FinTS 3.0. +const client = new FinTS4Client({ + url: "https://banking.example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", +}); + +const accounts = await client.accounts(); +const balance = await client.balance(accounts[0]); +const statements = await client.camtStatements(accounts[0]); +``` + +### Fetching Transactions (FinTS 3.0) + +```typescript +import { PinTanClient } from "fints-lib"; + +const client = new PinTanClient({ /* ... */ }); +const accounts = await client.accounts(); + +const startDate = new Date("2024-01-01"); +const endDate = new Date("2024-12-31"); +const statements = await client.statements(accounts[0], startDate, endDate); + +statements.forEach((statement) => { + statement.transactions.forEach((tx) => { + console.log(` ${tx.descriptionStructured?.bookingText}: ${tx.amount} ${tx.currency}`); + }); +}); +``` + +### Handling login TAN challenges (FinTS 3.0) + +Some banks require a TAN as part of the login dialog. When that happens the library raises a `TanRequiredError`: + +```typescript +import { TanRequiredError, TanProcessStep } from "fints-lib"; + +try { + const accounts = await client.accounts(); +} catch (error) { + if (error instanceof TanRequiredError) { + console.log("TAN Challenge:", error.challengeText); + console.log("Process Step:", error.getStepDescription()); + + const dialog = await client.completeLogin(error.dialog, error.transactionReference, "123456"); + const accounts = await client.accounts(dialog); + await dialog.end(); + } +} +``` + +### Interactive TAN via callback (FinTS 4.1 — Experimental) + +```typescript +import { FinTS4Client } from "fints-lib"; +import * as readline from "readline"; + +async function promptTan(challenge: { challengeText?: string; transactionReference: string }): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`TAN: ${challenge.challengeText ?? ""}\nEnter TAN: `, (tan) => { rl.close(); resolve(tan.trim()); }); + }); +} + +const client = new FinTS4Client({ + url: "https://banking.example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", + tanCallback: promptTan, // invoked automatically when the bank issues a challenge +}); + +const accounts = await client.accounts(); +const statements = await client.camtStatements(accounts[0]); +``` + +[Submitting SEPA credit transfers](#submitting-a-credit-transfer) +[Submitting SEPA direct debits](#submitting-a-direct-debit) + +[Further code examples](README_advanced_usage.md) + +## Features + +- **FinTS 3.0 (Stable)**: Full support for FinTS 3.0 (HBCI 300) — accounts, balances, statements, credit transfers, direct debits +- **FinTS 4.1 (Experimental)**: XML-based protocol — accounts, balances, camt.053 statements, interactive TAN, version negotiation +- **Enhanced Error Handling**: Comprehensive error code mapping with specific exception types +- **Timeout & Retry**: Configurable HTTP timeouts and automatic retry with exponential backoff +- **Multi-Step TAN Flows**: Enhanced support for complex TAN authentication flows +- Parse MT940, camt.052, camt.053 statement formats +- Extract SEPA reference tags from transaction descriptions + +## Configuration Options + +### FinTS 3.0 (PinTanClient) + +```typescript +const client = new PinTanClient({ + url: "https://example.com/fints", + name: "username", + pin: "12345", + blz: "12345678", + timeout: 45000, // ms, default 30000 + maxRetries: 5, // default 3 + retryDelay: 2000, // ms, default 1000 + debug: true, // logs XML – disable in production +}); +``` + +### FinTS 4.1 (FinTS4Client — Experimental) + +```typescript +import { FinTS4Client, createTlsAgent } from "fints-lib"; +import fs from "fs"; + +const client = new FinTS4Client({ + url: "https://example.com/fints41", + name: "username", + pin: "12345", + blz: "12345678", + timeout: 45000, + maxRetries: 3, + debug: false, + // Interactive TAN callback (invoked automatically on 0030 challenges): + tanCallback: async (challenge) => promptUser(challenge.challengeText), + // Preferred HBCI version — falls back to "4.0", then "3.0" on rejection: + preferredHbciVersion: "4.1", + // Custom TLS agent for banks with private CA certificates (Node.js only): + fetchOptions: { + agent: createTlsAgent({ + ca: fs.readFileSync("/path/to/bank-ca.pem", "utf8"), + }), + }, +}); +``` + +## Error Handling + +```typescript +import { + FinTSError, + AuthenticationError, + PinError, + StrongAuthenticationRequiredError, +} from "fints-lib"; + +try { + const accounts = await client.accounts(); +} catch (error) { + if (error instanceof PinError) { + console.error("PIN is incorrect:", error.message); + } else if (error instanceof AuthenticationError) { + console.error("Authentication failed:", error.message); + } else if (error instanceof StrongAuthenticationRequiredError) { + console.error("PSD2 strong customer authentication required:", error.message); + } else if (error instanceof FinTSError) { + console.error("FinTS error:", error.code, error.message); + } +} +``` + +### FinTS 4.1 TAN errors + +When a FinTS 4.1 server requires a TAN but no `tanCallback` is configured, a `FinTS4TanRequiredError` is thrown: + +```typescript +import { FinTS4Client, FinTS4TanRequiredError } from "fints-lib"; + +try { + const statements = await client.camtStatements(accounts[0]); +} catch (error) { + if (error instanceof FinTS4TanRequiredError) { + console.error("TAN required:", error.challengeText); + // Solution: configure tanCallback in FinTS4Client + } +} +``` + +## Common Use Cases + +### Check Account Balance + +```typescript +const accounts = await client.accounts(); +const balance = await client.balance(accounts[0]); +console.log(`Current Balance: ${balance.value.value} ${balance.value.currency}`); +``` + +### Fetch Recent Transactions + +```typescript +const endDate = new Date(); +const startDate = new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000); +const statements = await client.statements(accounts[0], startDate, endDate); + +statements.forEach((statement) => { + statement.transactions.forEach((tx) => { + console.log(`${tx.amount} ${tx.currency} — ${tx.purpose || ""}`); + }); +}); +``` + +### Working with Environment Variables (Recommended) + +```typescript +if (!process.env.FINTS_URL || !process.env.FINTS_USERNAME || !process.env.FINTS_PIN || !process.env.FINTS_BLZ) { + throw new Error("Required environment variables are not set"); +} +const client = new PinTanClient({ + url: process.env.FINTS_URL, + name: process.env.FINTS_USERNAME, + pin: process.env.FINTS_PIN, + blz: process.env.FINTS_BLZ, + debug: process.env.NODE_ENV === "development", +}); +``` + +## Submitting a credit transfer + +```typescript +import { PinTanClient, TanRequiredError, CreditTransferRequest } from "fints-lib"; + +const transfer: CreditTransferRequest = { + debtorName: "John Doe", + creditor: { name: "ACME GmbH", iban: "DE44500105175407324931", bic: "INGDDEFFXXX" }, + amount: 100.0, + remittanceInformation: "Invoice 0815", +}; + +try { + const submission = await client.creditTransfer(account, transfer); + console.log(submission.taskId); +} catch (error) { + if (error instanceof TanRequiredError) { + const submission = error.creditTransferSubmission!; + const completed = await client.completeCreditTransfer( + error.dialog, error.transactionReference, "123456", submission, + ); + console.log(completed.taskId); + } +} +``` + +## Submitting a direct debit + +```typescript +import { PinTanClient, TanRequiredError, DirectDebitRequest } from "fints-lib"; + +const debit: DirectDebitRequest = { + creditorName: "ACME GmbH", + creditorId: "DE98ZZZ09999999999", + debtor: { name: "John Doe", iban: "DE02120300000000202051" }, + amount: 42.5, + mandateId: "MANDATE-123", + mandateSignatureDate: new Date("2022-01-10"), + requestedCollectionDate: new Date(), + remittanceInformation: "Invoice 0815", +}; + +try { + const submission = await client.directDebit(account, debit); + console.log(submission.taskId); +} catch (error) { + if (error instanceof TanRequiredError) { + const submission = error.directDebitSubmission!; + const completed = await client.completeDirectDebit( + error.dialog, error.transactionReference, "123456", submission, + ); + console.log(completed.taskId); + } +} +``` + +## Resources + +- [API Reference](https://prior99.gitlab.io/fints) +- [Official specification](https://www.hbci-zka.de/spec/3_0.htm) +- [Database of banks with their URLs](https://github.com/jhermsmeier/fints-institute-db) + + ## Installation ```bash diff --git a/packages/fints/package.json b/packages/fints/package.json index 84103ef..7bf6209 100644 --- a/packages/fints/package.json +++ b/packages/fints/package.json @@ -1,6 +1,6 @@ { "name": "fints-lib", - "version": "0.10.0", + "version": "0.11.0", "description": "FinTS client library with psd2 support", "keywords": [ "fints", diff --git a/packages/fints/src/index.ts b/packages/fints/src/index.ts index a5b3de0..fe013c4 100644 --- a/packages/fints/src/index.ts +++ b/packages/fints/src/index.ts @@ -23,3 +23,5 @@ export * from "./errors/tan-required-error"; export * from "./errors/fints-error"; export * from "./errors/decoupled-tan-error"; export * from "./decoupled-tan"; +export * from "./v4"; +export * from "./negotiating-client"; diff --git a/packages/fints/src/negotiating-client.ts b/packages/fints/src/negotiating-client.ts new file mode 100644 index 0000000..69a66b8 --- /dev/null +++ b/packages/fints/src/negotiating-client.ts @@ -0,0 +1,243 @@ +/** + * NegotiatingClient - Automatic FinTS protocol version negotiation. + * + * This client automatically detects whether the bank supports FinTS 4.1 + * and falls back to FinTS 3.0 if not. It provides a unified API regardless + * of the underlying protocol version. + * + * **Negotiation Strategy:** + * 1. First attempt connection with FinTS 3.0 (the more common protocol) + * 2. During synchronization, check if the bank advertises FinTS 4.1 support + * 3. If FinTS 4.1 is supported and preferred, switch to v4.1 client + * 4. If FinTS 4.1 connection fails, fall back to v3.0 + * + * Usage: + * ```typescript + * const client = new NegotiatingClient({ + * blz: "12345678", + * name: "username", + * pin: "12345", + * url: "https://banking.example.com/fints", + * }); + * + * // Automatically uses the best available protocol version + * const accounts = await client.accounts(); + * const statements = await client.statements(accounts[0]); + * ``` + */ +import { PinTanClient, PinTanClientConfig } from "./pin-tan-client"; +import { FinTS4Client } from "./v4/client"; +import { SEPAAccount, Balance, Statement, BankCapabilities } from "./types"; +import { verbose } from "./logger"; + +/** + * Detected FinTS protocol version. + */ +export type FinTSProtocolVersion = "3.0" | "4.1"; + +/** + * Configuration for the negotiating client. + */ +export interface NegotiatingClientConfig extends PinTanClientConfig { + /** + * Preferred protocol version. + * If set to "4.1", will try FinTS 4.1 first and fall back to 3.0. + * If set to "3.0", will only use FinTS 3.0. + * Default: "3.0" (for maximum compatibility) + */ + preferredVersion?: FinTSProtocolVersion; + + /** + * URL for the FinTS 4.1 endpoint. + * Some banks use a different endpoint for v4.1. + * If not provided, uses the same URL as for v3.0. + */ + v4Url?: string; + + /** + * Maximum number of retry attempts for v4.1 connections. + */ + maxRetries?: number; + + /** + * Base delay for retry backoff in milliseconds. + */ + retryDelay?: number; + + /** + * Callback invoked when a FinTS 4.1 server issues a TAN challenge. + * If not provided and a TAN is required during a v4.1 request, a + * `FinTS4TanRequiredError` is thrown. + */ + tanCallback?: import("./v4/types").TanCallback; + + /** + * Additional options passed directly to the underlying `fetch()` call + * for FinTS 4.1 requests (e.g. a custom TLS `https.Agent`). + */ + fetchOptions?: Record; + + /** + * TLS options for FinTS 4.1 connections (Node.js only). + * Converted automatically to a `fetchOptions.agent` via `createTlsAgent()`. + */ + tlsOptions?: import("./v4/types").FinTS4TlsOptions; + + /** + * Preferred HBCI version for FinTS 4.1 negotiation (e.g. "4.1", "4.0"). + * Defaults to "4.1". Falls back automatically when the bank rejects the version. + */ + preferredHbciVersion?: string; +} + +/** + * A client that automatically negotiates the FinTS protocol version. + * + * This provides a unified interface for both FinTS 3.0 and 4.1, automatically + * selecting the best available protocol version based on server capabilities. + */ +export class NegotiatingClient { + private config: NegotiatingClientConfig; + private v3Client: PinTanClient; + private v4Client: FinTS4Client | null = null; + private detectedVersion: FinTSProtocolVersion | null = null; + + constructor(config: NegotiatingClientConfig) { + this.config = config; + this.v3Client = new PinTanClient(config); + + if (config.preferredVersion === "4.1") { + this.v4Client = this.createV4Client(config); + } + } + + /** + * Create a FinTS4Client from the negotiating config. + */ + private createV4Client(config: NegotiatingClientConfig): FinTS4Client { + return new FinTS4Client({ + blz: config.blz, + name: config.name, + pin: config.pin, + url: config.v4Url || config.url, + productId: config.productId, + debug: config.debug, + timeout: config.timeout, + maxRetries: config.maxRetries, + retryDelay: config.retryDelay, + tanCallback: config.tanCallback, + fetchOptions: config.fetchOptions, + tlsOptions: config.tlsOptions, + preferredHbciVersion: config.preferredHbciVersion, + }); + } + + /** + * Get the detected/active protocol version. + * Returns null if no connection has been established yet. + */ + public get protocolVersion(): FinTSProtocolVersion | null { + return this.detectedVersion; + } + + /** + * Detect the supported protocol version by attempting to connect. + * + * @returns The detected protocol version. + */ + public async detectVersion(): Promise { + if (this.detectedVersion) { + return this.detectedVersion; + } + + if (this.config.preferredVersion === "4.1" && this.v4Client) { + try { + verbose("NegotiatingClient: Attempting FinTS 4.1 connection..."); + await this.v4Client.capabilities(); + this.detectedVersion = "4.1"; + verbose("NegotiatingClient: FinTS 4.1 connection successful."); + return "4.1"; + } catch (error) { + verbose(`NegotiatingClient: FinTS 4.1 failed, falling back to 3.0: ${(error as Error).message}`); + } + } + + this.detectedVersion = "3.0"; + verbose("NegotiatingClient: Using FinTS 3.0."); + return "3.0"; + } + + /** + * Retrieve the capabilities of the bank. + */ + public async capabilities(): Promise { + const version = await this.detectVersion(); + if (version === "4.1" && this.v4Client) { + return this.v4Client.capabilities(); + } + return this.v3Client.capabilities(); + } + + /** + * Fetch a list of all SEPA accounts. + */ + public async accounts(): Promise { + const version = await this.detectVersion(); + if (version === "4.1" && this.v4Client) { + return this.v4Client.accounts(); + } + return this.v3Client.accounts(); + } + + /** + * Fetch the balance for a SEPA account. + */ + public async balance(account: SEPAAccount): Promise { + const version = await this.detectVersion(); + if (version === "4.1" && this.v4Client) { + return this.v4Client.balance(account); + } + return this.v3Client.balance(account); + } + + /** + * Fetch account statements. + * + * When using FinTS 4.1, statements are fetched in camt.053 format and + * converted to the common Statement format for backward compatibility. + */ + public async statements(account: SEPAAccount, startDate?: Date, endDate?: Date): Promise { + const version = await this.detectVersion(); + if (version === "4.1" && this.v4Client) { + return this.v4Client.statements(account, startDate, endDate); + } + return this.v3Client.statements(account, startDate, endDate); + } + + /** + * Force using a specific protocol version. + * Useful when you know which version the bank supports. + */ + public forceVersion(version: FinTSProtocolVersion): void { + this.detectedVersion = version; + if (version === "4.1" && !this.v4Client) { + this.v4Client = this.createV4Client(this.config); + } + } + + /** + * Get the underlying v3.0 client. + * Useful for operations only supported in v3.0 (e.g., credit transfers). + */ + public getV3Client(): PinTanClient { + return this.v3Client; + } + + /** + * Get the underlying v4.1 client. + * Returns null if v4.1 is not configured. + */ + public getV4Client(): FinTS4Client | null { + return this.v4Client; + } +} diff --git a/packages/fints/src/test-server/__tests__/test-mock-bank-v3-integration.ts b/packages/fints/src/test-server/__tests__/test-mock-bank-v3-integration.ts new file mode 100644 index 0000000..f5ea442 --- /dev/null +++ b/packages/fints/src/test-server/__tests__/test-mock-bank-v3-integration.ts @@ -0,0 +1,211 @@ +/** + * Integration tests for FinTS 3.0 using the Mock Bank Server. + * + * These tests exercise the complete client ↔ server interaction + * through a simulated bank server that returns realistic test data. + */ +import { Dialog, DialogConfig } from "../../dialog"; +import { MockBankServerV3 } from "../mock-bank-server"; +import { TEST_BANK_V3, TEST_ACCOUNTS_V3 } from "../test-data"; +import { HKSPA, HKSAL, HKKAZ } from "../../segments"; +import { Request } from "../../request"; + +describe("FinTS 3.0 Integration: Mock Bank Server", () => { + let server: MockBankServerV3; + let dialog: Dialog; + + function makeConfig(): DialogConfig { + const config = new DialogConfig(); + config.blz = TEST_BANK_V3.blz; + config.name = "testuser"; + config.pin = "12345"; + config.systemId = "0"; + return config; + } + + beforeEach(() => { + server = new MockBankServerV3(); + dialog = new Dialog(makeConfig(), server); + }); + + afterEach(() => { + server.reset(); + }); + + // ----------------------------------------------------------------------- + // Authentication + // ----------------------------------------------------------------------- + + describe("Authentication", () => { + it("rejects unknown users with 9931", async () => { + const cfg = makeConfig(); + cfg.name = "unknownuser"; + const badDialog = new Dialog(cfg, server); + await expect(badDialog.sync()).rejects.toThrow(/9931/); + }); + + it("rejects invalid PIN with 9340", async () => { + const cfg = makeConfig(); + cfg.pin = "wrongpin"; + const badDialog = new Dialog(cfg, server); + await expect(badDialog.sync()).rejects.toThrow(/9340/); + }); + + it("authenticates valid user", async () => { + await dialog.sync(); + expect(dialog.systemId).toBeTruthy(); + expect(dialog.systemId).not.toBe("0"); + }); + }); + + // ----------------------------------------------------------------------- + // Synchronization + // ----------------------------------------------------------------------- + + describe("Synchronization", () => { + it("obtains a system ID from the server", async () => { + await dialog.sync(); + expect(dialog.systemId).toMatch(/^SYS/); + }); + + it("receives TAN methods", async () => { + await dialog.sync(); + expect(dialog.tanMethods.length).toBeGreaterThan(0); + }); + + it("detects HISALS support (balance)", async () => { + await dialog.sync(); + expect(dialog.supportsBalance).toBe(true); + }); + + it("detects HIKAZS support (statements)", async () => { + await dialog.sync(); + expect(dialog.supportsTransactions).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // Dialog Lifecycle + // ----------------------------------------------------------------------- + + describe("Dialog Lifecycle", () => { + it("sync → init → end", async () => { + await dialog.sync(); + expect(dialog.dialogId).toBe("0"); // sync calls end() + + await dialog.init(); + expect(dialog.dialogId).toMatch(/^DLG-/); + + await dialog.end(); + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + }); + + it("logs requests and responses", async () => { + await dialog.sync(); + // sync sends 2 messages: sync + end + expect(server.requestLog.length).toBe(2); + expect(server.responseLog.length).toBe(2); + }); + }); + + // ----------------------------------------------------------------------- + // Account List (HKSPA → HISPA) + // ----------------------------------------------------------------------- + + describe("Account List", () => { + it("retrieves accounts via HKSPA", async () => { + await dialog.sync(); + await dialog.init(); + + const { blz, name, pin, systemId, dialogId, msgNo } = dialog; + const segments = [new HKSPA({ segNo: 3 })]; + const request = new Request({ blz, name, pin, systemId, dialogId, msgNo, segments }); + const response = await dialog.send(request); + + expect(response.success).toBe(true); + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Balance Query (HKSAL → HISAL) + // ----------------------------------------------------------------------- + + describe("Balance Query", () => { + it("retrieves balance via HKSAL", async () => { + await dialog.sync(); + await dialog.init(); + + const account = TEST_ACCOUNTS_V3[0]; + const { blz, name, pin, systemId, dialogId, msgNo } = dialog; + const segments = [ + new HKSAL({ + segNo: 3, + version: dialog.hisalsVersion, + account: { + iban: account.iban, + bic: account.bic, + accountNumber: account.accountNumber, + blz: account.blz, + subAccount: "", + }, + }), + ]; + const request = new Request({ blz, name, pin, systemId, dialogId, msgNo, segments }); + const response = await dialog.send(request); + + expect(response.success).toBe(true); + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Statements (HKKAZ → HIKAZ) + // ----------------------------------------------------------------------- + + describe("Account Statements", () => { + it("retrieves MT940 statement data via HKKAZ", async () => { + await dialog.sync(); + await dialog.init(); + + const account = TEST_ACCOUNTS_V3[0]; + const { blz, name, pin, systemId, dialogId, msgNo } = dialog; + const segments = [ + new HKKAZ({ + segNo: 3, + version: dialog.hikazsVersion, + account: { + iban: account.iban, + bic: account.bic, + accountNumber: account.accountNumber, + blz: account.blz, + subAccount: "", + }, + startDate: new Date("2024-01-01"), + endDate: new Date("2024-12-31"), + }), + ]; + const request = new Request({ blz, name, pin, systemId, dialogId, msgNo, segments }); + const response = await dialog.send(request); + + expect(response.success).toBe(true); + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Server State + // ----------------------------------------------------------------------- + + describe("Server State", () => { + it("reset clears all state", async () => { + await dialog.sync(); + expect(server.requestLog.length).toBeGreaterThan(0); + + server.reset(); + expect(server.requestLog).toHaveLength(0); + expect(server.responseLog).toHaveLength(0); + }); + }); +}); diff --git a/packages/fints/src/test-server/index.ts b/packages/fints/src/test-server/index.ts new file mode 100644 index 0000000..143e633 --- /dev/null +++ b/packages/fints/src/test-server/index.ts @@ -0,0 +1,5 @@ +/** + * FinTS 3.0 Test Server – public exports. + */ +export { MockBankServerV3 } from "./mock-bank-server"; +export { TEST_BANK_V3, TEST_USERS_V3, TEST_TAN_METHODS_V3, TEST_ACCOUNTS_V3, buildTestMT940 } from "./test-data"; diff --git a/packages/fints/src/test-server/mock-bank-server.ts b/packages/fints/src/test-server/mock-bank-server.ts new file mode 100644 index 0000000..4065bdd --- /dev/null +++ b/packages/fints/src/test-server/mock-bank-server.ts @@ -0,0 +1,541 @@ +/** + * FinTS 3.0 Mock Bank Server + * + * A simulated FinTS 3.0 server that processes `Request` objects and returns + * `Response` objects with realistic test data. Designed for integration testing + * of the FinTS 3.0 client without requiring a real bank connection. + * + * Features: + * - Full dialog lifecycle support (Sync → Init → Business → End) + * - PIN authentication validation + * - Account listing (HISPA), balance queries (HISAL), statements (HIKAZ) + * - Realistic return codes per FinTS 3.0 specification + * - TAN method advertisement (HITANS) + * - System ID assignment (HISYN) + * - BPD and UPD segments + */ +import { Connection } from "../types"; +import { Request } from "../request"; +import { Response } from "../response"; +import { HEADER_LENGTH } from "../constants"; +import { TEST_BANK_V3, TEST_USERS_V3, TEST_TAN_METHODS_V3, TEST_ACCOUNTS_V3, buildTestMT940 } from "./test-data"; + +/** + * State of a dialog in the mock server. + */ +interface DialogState { + dialogId: string; + userId: string; + authenticated: boolean; + msgNo: number; + systemId: string; +} + +/** + * A simulated FinTS 3.0 bank server for integration testing. + * + * Implements the `Connection` interface so it can be directly injected + * into a `Dialog` or `PinTanClient` as the connection layer. + * + * Usage: + * ```typescript + * const server = new MockBankServerV3(); + * const dialog = new Dialog(config, server); + * await dialog.sync(); + * ``` + */ +export class MockBankServerV3 implements Connection { + /** Active dialogs keyed by dialog ID. */ + private dialogs = new Map(); + /** Counter for generating dialog IDs. */ + private nextDialogId = 1000; + /** Counter for generating system IDs. */ + private nextSystemId = 1; + /** All received request strings (for test assertions). */ + public requestLog: string[] = []; + /** All sent response strings (for test assertions). */ + public responseLog: string[] = []; + + /** + * Create a v3.0 Connection that routes through this mock server. + * Same as `this` since MockBankServerV3 implements Connection. + */ + public createConnection(): Connection { + return this; + } + + /** + * Reset the server state (clear all dialogs and logs). + */ + public reset(): void { + this.dialogs.clear(); + this.requestLog = []; + this.responseLog = []; + this.nextDialogId = 1000; + this.nextSystemId = 1; + // Reset user system IDs + for (const user of Object.values(TEST_USERS_V3)) { + user.systemId = ""; + } + } + + /** + * Process an incoming FinTS 3.0 request and return a response. + * Implements the Connection interface. + */ + public async send(request: Request): Promise { + const requestStr = String(request); + this.requestLog.push(requestStr); + + try { + const responseStr = this.handleRequest(request); + this.responseLog.push(responseStr); + return new Response(responseStr); + } catch (error) { + const errorResponse = this.buildErrorResponse( + String(request.dialogId || "0"), + request.msgNo || 1, + "9010", + `Verarbeitung nicht möglich: ${(error as Error).message}`, + ); + this.responseLog.push(errorResponse); + return new Response(errorResponse); + } + } + + /** + * Process the request and build a raw FinTS 3.0 response string. + */ + private handleRequest(request: Request): string { + // Extract segment types from the request + const segmentTypes = request.segments.map((s) => s.type); + const userId = request.name; + const pin = request.pin; + const dialogId = request.dialogId || "0"; + const msgNo = request.msgNo || 1; + + // Authenticate + const authError = this.authenticate(userId, pin); + + if (segmentTypes.includes("HKSYN")) { + if (authError) return authError; + return this.handleSync(userId, dialogId, msgNo); + } + + if (segmentTypes.includes("HKEND")) { + return this.handleDialogEnd(dialogId, msgNo); + } + + // For any other request, check if we have an auth error from the initial request + if (segmentTypes.includes("HKIDN") && !segmentTypes.includes("HKSYN")) { + if (authError) return authError; + return this.handleDialogInit(userId, dialogId, msgNo); + } + + if (segmentTypes.includes("HKSPA")) { + return this.handleAccountList(dialogId, msgNo); + } + + if (segmentTypes.includes("HKSAL")) { + const hksal = request.segments.find((s) => s.type === "HKSAL"); + return this.handleBalance(dialogId, msgNo, hksal); + } + + if (segmentTypes.includes("HKKAZ")) { + const hkkaz = request.segments.find((s) => s.type === "HKKAZ"); + return this.handleStatements(dialogId, msgNo, hkkaz); + } + + return this.buildErrorResponse(dialogId, msgNo, "9010", "Unbekannter Geschäftsvorfall"); + } + + // ----------------------------------------------------------------------- + // Request handlers + // ----------------------------------------------------------------------- + + private handleSync(userId: string, _dialogId: string, msgNo: number): string { + const newDialogId = `SYN-${this.nextDialogId++}`; + const systemId = `SYS${String(this.nextSystemId++).padStart(10, "0")}`; + + // Store system ID for this user + if (TEST_USERS_V3[userId]) { + TEST_USERS_V3[userId].systemId = systemId; + } + + this.dialogs.set(newDialogId, { + dialogId: newDialogId, + userId, + authenticated: true, + msgNo: msgNo + 1, + systemId, + }); + + const innerSegments = [ + // HNSHK (signature header) + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + // HIRMG (message return codes) + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`, `0020::Auftrag ausgefuehrt`]), + // HIRMS (segment return codes) + this.buildSegment("HIRMS", 4, 2, [ + `3920::Zugelassene TAN-Verfahren fur den Benutzer:${TEST_TAN_METHODS_V3.map((m) => m.securityFunction).join(":")}`, + `0020::Synchronisierung durchgefuehrt`, + ]), + // HISYN (system ID) + this.buildSegment("HISYN", 5, 4, [systemId]), + // HIBPA (bank parameter data) + this.buildBPASegment(6), + // HISPAS (SEPA account parameter) + this.buildSegment("HISPAS", 7, 1, [ + `1`, + `1`, + `1`, + `urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03`, + `urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.002.003.03`, + ]), + // HISALS (balance parameter) + this.buildSegment("HISALS", 8, 7, [`1`, `1`]), + // HIKAZS (statement parameter) + this.buildSegment("HIKAZS", 9, 7, [`1`, `1`, `365`, `J`]), + // HITANS (TAN methods) + this.buildTanMethodsSegment(10), + // HIUPD (user data) + ...this.buildHIUPDSegments(11, userId), + // HNSHA (signature end) + this.buildSegment("HNSHA", 99, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(newDialogId, msgNo, innerSegments); + } + + private handleDialogInit(userId: string, _dialogId: string, msgNo: number): string { + const newDialogId = `DLG-${this.nextDialogId++}`; + + this.dialogs.set(newDialogId, { + dialogId: newDialogId, + userId, + authenticated: true, + msgNo: msgNo + 1, + systemId: TEST_USERS_V3[userId]?.systemId || "0", + }); + + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`]), + this.buildSegment("HIRMS", 4, 2, [`0020::Auftrag ausgefuehrt`]), + this.buildSegment("HNSHA", 5, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(newDialogId, msgNo, innerSegments); + } + + private handleDialogEnd(dialogId: string, msgNo: number): string { + this.dialogs.delete(dialogId); + + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`]), + this.buildSegment("HIRMS", 4, 2, [`0100::Dialog beendet`]), + this.buildSegment("HNSHA", 5, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(dialogId, msgNo, innerSegments); + } + + private handleAccountList(dialogId: string, msgNo: number): string { + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`]), + this.buildSegment("HIRMS", 4, 2, [`0020::Auftrag ausgefuehrt`]), + ...this.buildHISPASegments(5), + this.buildSegment("HNSHA", 99, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(dialogId, msgNo, innerSegments); + } + + private handleBalance(dialogId: string, msgNo: number, _segment: any): string { + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`]), + this.buildSegment("HIRMS", 4, 2, [`0020::Kontosaldo ermittelt`]), + this.buildHISALSegment(5), + this.buildSegment("HNSHA", 6, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(dialogId, msgNo, innerSegments); + } + + private handleStatements(dialogId: string, msgNo: number, _segment: any): string { + const account = TEST_ACCOUNTS_V3[0]; + const mt940 = buildTestMT940(account.accountNumber, account.blz, account.currency); + + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`0010::Nachricht entgegengenommen`]), + this.buildSegment("HIRMS", 4, 2, [`0020::Auftrag ausgefuehrt`]), + this.buildHIKAZSegment(5, mt940), + this.buildSegment("HNSHA", 6, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(dialogId, msgNo, innerSegments); + } + + // ----------------------------------------------------------------------- + // Helper methods + // ----------------------------------------------------------------------- + + private authenticate(userId: string, pin: string): string | null { + const user = TEST_USERS_V3[userId]; + if (!user) { + return this.buildErrorResponse("0", 1, "9931", "Zugang gesperrt - Benutzer unbekannt"); + } + if (pin !== user.pin) { + return this.buildErrorResponse("0", 1, "9340", "PIN ungueltig"); + } + return null; + } + + /** + * Build a single FinTS 3.0 segment string. + * Format: TYPE:SEGNO:VERSION+field1+field2' + */ + private buildSegment(type: string, segNo: number, version: number, fields: string[]): string { + return `${type}:${segNo}:${version}+${fields.join("+")}` + "'"; + } + + /** + * Wrap inner segments into a complete FinTS 3.0 response message. + */ + private wrapResponse(dialogId: string, msgNo: number, innerSegments: string[]): string { + const innerContent = innerSegments.join(""); + + // Build HNVSD wrapper + const hnvsd = `HNVSD:999:1+@${innerContent.length}@${innerContent}'`; + + // Build HNVSK + const hnvsk = `HNVSK:998:3+PIN:1+998+1+1::0+1:19700101:000000+2:2:13:@8@00000000:5:1+${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:V:0:0+0'`; + + // Build HNHBS + const hnhbs = `HNHBS:${innerSegments.length + 3}:1+${msgNo}'`; + + // Calculate total length for HNHBK + const segmentsWithoutHeader = hnvsk + hnvsd + hnhbs; + const totalLength = HEADER_LENGTH + dialogId.length + String(msgNo).length + segmentsWithoutHeader.length; + + // Build HNHBK + const hnhbk = `HNHBK:1:3+${String(totalLength).padStart(12, "0")}+${TEST_BANK_V3.hbciVersion}+${dialogId}+${msgNo}'`; + + return hnhbk + hnvsk + hnvsd + hnhbs; + } + + private buildErrorResponse(dialogId: string, msgNo: number, code: string, message: string): string { + const innerSegments = [ + this.buildSegment("HNSHK", 2, 4, [ + `PIN:1`, + `999`, + `1`, + `1`, + `1`, + `2::0`, + `1`, + `1:19700101:000000`, + `1:999:1`, + `6:10:16`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}:${TEST_BANK_V3.bankName}:S:0:0`, + ]), + this.buildSegment("HIRMG", 3, 2, [`${code}::${message}`]), + this.buildSegment("HNSHA", 4, 2, [`2`, ``, ``, ``]), + ]; + + return this.wrapResponse(dialogId, msgNo, innerSegments); + } + + private buildBPASegment(segNo: number): string { + return this.buildSegment("HIBPA", segNo, 3, [ + `${TEST_BANK_V3.bpdVersion}`, + `${TEST_BANK_V3.countryCode}:${TEST_BANK_V3.blz}`, + `${TEST_BANK_V3.bankName}`, + `3`, // Number of languages + `1`, // Default language + `3`, // Number of HBCI versions + `${TEST_BANK_V3.hbciVersion}`, + `0`, // Max message size + ]); + } + + private buildTanMethodsSegment(segNo: number): string { + // HITANS v7 format (FinTS 3.0 spec §C.4): + // maxRequests+minSignatures+securityClass+oneStepAllowed:multiple:securityProfile:method1_fields...:method2_fields...' + // + // All TAN method data goes into a SINGLE '+'-field (DEG), separated by ':'. + // Per-method field order for v7 (tanMethodArgumentMap v7, 30 fields): + // securityFunction, tanProcess, techId, zkaId, zkaVersion, name, + // maxLengthInput, allowedFormat, textReturnvalue, maxLengthReturnvalue, + // numberOfSupportedLists, multiple, tanTimeDialogAssociation, + // tanDialogOptions, tanListNumberRequired, cancellable, + // smsChargeAccountRequired, principalAccountRequired, + // challengeClassRequired, challengeValueRequired, challengeStructured, + // initializationMode, supportedMediaNumber, hhdUcRequired, + // activeTanMedia, decoupledMaxStatusRequests, + // decoupledWaitBeforeFirstStatusRequest, decoupledWaitBetweenStatusRequests, + // decoupledManualConfirmationAllowed, decoupledAutoConfirmationAllowed + const methodFields = TEST_TAN_METHODS_V3.map((m) => + [ + m.securityFunction, // 1 securityFunction + m.tanProcess, // 2 tanProcess + m.techId, // 3 techId + "", // 4 zkaId + "", // 5 zkaVersion + m.name, // 6 name + m.maxLengthInput, // 7 maxLengthInput + m.allowedFormat, // 8 allowedFormat + "", // 9 textReturnvalue + m.maxLengthInput, // 10 maxLengthReturnvalue + "1", // 11 numberOfSupportedLists + "J", // 12 multiple + "1", // 13 tanTimeDialogAssociation + "0", // 14 tanDialogOptions + "N", // 15 tanListNumberRequired + "J", // 16 cancellable + "N", // 17 smsChargeAccountRequired + "N", // 18 principalAccountRequired + "N", // 19 challengeClassRequired + "N", // 20 challengeValueRequired + "N", // 21 challengeStructured + "00", // 22 initializationMode + "1", // 23 supportedMediaNumber + "N", // 24 hhdUcRequired + "0", // 25 activeTanMedia + m.numStatusRequests, // 26 decoupledMaxStatusRequests + m.firstDelaySeconds, // 27 decoupledWaitBeforeFirstStatusRequest + m.delayBetweenSeconds, // 28 decoupledWaitBetweenStatusRequests + "N", // 29 decoupledManualConfirmationAllowed + "N", // 30 decoupledAutoConfirmationAllowed + ].join(":"), + ).join(":"); + + // The 4th DEG: oneStepAllowed:multiple:securityProfile: + const tanDeg = `J:N:900:${methodFields}`; + + return this.buildSegment("HITANS", segNo, 7, [`1`, `1`, `1`, tanDeg]); + } + + private buildHIUPDSegments(startSegNo: number, userId: string): string[] { + const user = TEST_USERS_V3[userId]; + return TEST_ACCOUNTS_V3.map((acc, i) => { + const segNo = startSegNo + i; + return this.buildSegment("HIUPD", segNo, 6, [ + `${acc.accountType}::${TEST_BANK_V3.countryCode}:${acc.blz}:${acc.accountNumber}`, + acc.iban, + userId, + `${acc.accountType}`, + acc.currency, + user?.name || "", + ``, // account limit + ``, // extension + `HKSPA:1+HKSAL:1+HKKAZ:1`, // allowed transactions + ]); + }); + } + + private buildHISPASegments(startSegNo: number): string[] { + return TEST_ACCOUNTS_V3.map((acc, i) => { + const segNo = startSegNo + i; + return this.buildSegment("HISPA", segNo, 1, [ + `J:${acc.iban}:${acc.bic}:${acc.accountNumber}:${acc.subAccount}:${TEST_BANK_V3.countryCode}:${acc.blz}`, + ]); + }); + } + + private buildHISALSegment(segNo: number): string { + const account = TEST_ACCOUNTS_V3[0]; + const today = new Date(); + const dateStr = today.toISOString().slice(0, 10).replace(/-/g, ""); + + return this.buildSegment("HISAL", segNo, 7, [ + `${account.accountType}::${TEST_BANK_V3.countryCode}:${account.blz}:${account.accountNumber}`, + account.accountNumber, + account.currency, + `C:13095,67:EUR:${dateStr}`, // Booked balance + `C:13095,67:EUR:${dateStr}`, // Available balance (same) + `5000,00:EUR`, // Credit limit + ]); + } + + private buildHIKAZSegment(segNo: number, mt940Data: string): string { + // HIKAZ wraps MT940 data using @length@ binary notation + return `HIKAZ:${segNo}:7:4+@${mt940Data.length}@${mt940Data}'`; + } +} diff --git a/packages/fints/src/test-server/test-data.ts b/packages/fints/src/test-server/test-data.ts new file mode 100644 index 0000000..f64a906 --- /dev/null +++ b/packages/fints/src/test-server/test-data.ts @@ -0,0 +1,128 @@ +/** + * Test data for the FinTS 3.0 mock bank server. + * + * Contains realistic German banking test data following the FinTS 3.0 specification. + * All data is fictional – no real bank accounts or persons are referenced. + */ + +/** + * Bank configuration for the test server. + */ +export const TEST_BANK_V3 = { + blz: "76050101", + bankName: "Sparkasse Teststadt", + bic: "SSKNDE77XXX", + bpdVersion: 85, + updVersion: 3, + countryCode: "280", + hbciVersion: "300", +}; + +/** + * Test users recognized by the mock server. + */ +export const TEST_USERS_V3: Record = { + testuser: { + pin: "12345", + name: "Max Mustermann", + systemId: "", + }, + testuser2: { + pin: "54321", + name: "Erika Musterfrau", + systemId: "", + }, +}; + +/** + * TAN methods offered by the mock server. + * These follow the HITANS segment format. + */ +export const TEST_TAN_METHODS_V3 = [ + { + securityFunction: "912", + tanProcess: "2", + techId: "pushTAN", + name: "pushTAN 2.0", + maxLengthInput: 6, + allowedFormat: "1", + returnValueLength: 0, + numStatusRequests: 60, + firstDelaySeconds: 5, + delayBetweenSeconds: 2, + }, + { + securityFunction: "913", + tanProcess: "1", + techId: "chipTAN", + name: "chipTAN QR", + maxLengthInput: 8, + allowedFormat: "0", + returnValueLength: 0, + numStatusRequests: 0, + firstDelaySeconds: 0, + delayBetweenSeconds: 0, + }, +]; + +/** + * Test accounts for the mock server. + */ +export const TEST_ACCOUNTS_V3 = [ + { + iban: "DE89370400440532013000", + bic: "SSKNDE77XXX", + accountNumber: "0532013000", + subAccount: "", + blz: "76050101", + ownerName: "Max Mustermann", + currency: "EUR", + accountType: "1", + }, + { + iban: "DE27100777770209299700", + bic: "SSKNDE77XXX", + accountNumber: "0209299700", + subAccount: "", + blz: "76050101", + ownerName: "Max Mustermann", + currency: "EUR", + accountType: "1", + }, +]; + +/** + * Build a realistic MT940 statement for a test account. + * MT940 is the SWIFT format used by FinTS 3.0 for account statements. + */ +export function buildTestMT940(accountNumber: string, blz: string, currency = "EUR"): string { + const today = new Date(); + const yy = String(today.getFullYear()).slice(2); + const mm = String(today.getMonth() + 1).padStart(2, "0"); + const dd = String(today.getDate()).padStart(2, "0"); + const dateShort = `${yy}${mm}${dd}`; + + return [ + `:20:STARTUMS`, + `:25:${blz}/${accountNumber}`, + `:28C:00042/001`, + `:60F:C${dateShort}${currency}12345,67`, + // Salary credit + `:61:${dateShort}${dateShort}CR2500,00N051NONREF`, + `:86:166?00SEPA Credit Transfer?10SALARY?20SVWZ+Gehalt ${mm}/${today.getFullYear()}?21EREF+GEHALT-${dateShort}?22KREF+MNDT-GEHALT-001?30INGDDEFFXXX?31DE44500105175407324931?32Arbeitgeber Test GmbH?34912`, + // Rent debit + `:61:${dateShort}${dateShort}DR850,00N051NONREF`, + `:86:166?00SEPA Credit Transfer?10RENT?20SVWZ+Miete Wohnung 4B?21EREF+MIETE-${dateShort}?30COBADEFFXXX?31DE27100777770209299700?32Immobilien Verwaltung GmbH?34912`, + // Utility debit + `:61:${dateShort}${dateShort}DR45,99N051NONREF`, + `:86:166?00SEPA Direct Debit?10UTIL?20SVWZ+Abschlag Strom/Gas?21EREF+STROM-${dateShort}?22KREF+MNDT-STROM-001?30COBADEFFXXX?31DE91100000000123456789?32Stadtwerke Teststadt?34912`, + // Freelance credit + `:61:${dateShort}${dateShort}CR1100,00N051NONREF`, + `:86:166?00SEPA Credit Transfer?10FREELNC?20SVWZ+Rechnung RE-2024-042?21EREF+FREIBERUF-${dateShort}?30SOLADEST600?31DE75512108001245126199?32Consulting Kunde AG?34912`, + // Grocery debit + `:61:${dateShort}${dateShort}DR54,01N051NONREF`, + `:86:166?00SEPA Credit Transfer?10GROCRY?20SVWZ+REWE SAGT DANKE 54712?21EREF+EINKAUF-${dateShort}?30COBADEFFXXX?31DE86200800000970375700?32REWE Markt GmbH?34912`, + `:62F:C${dateShort}${currency}13095,67`, + `-`, + ].join("\r\n"); +} diff --git a/packages/fints/src/v4/__tests__/test-camt-parser.ts b/packages/fints/src/v4/__tests__/test-camt-parser.ts new file mode 100644 index 0000000..537feda --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-camt-parser.ts @@ -0,0 +1,319 @@ +import { parseCamt053, parseCamt052 } from "../camt-parser"; + +describe("camt-parser", () => { + describe("parseCamt053", () => { + it("returns empty array for empty string", () => { + expect(parseCamt053("")).toEqual([]); + }); + + it("returns empty array for whitespace-only string", () => { + expect(parseCamt053(" ")).toEqual([]); + }); + + it("parses a basic camt.053 document with one statement", () => { + const xml = ` + + + + STMT001 + + + DE89370400440532013000 + + + 2024-01-15T10:00:00 + + + `; + + const result = parseCamt053(xml); + expect(result.length).toBe(1); + expect(result[0].id).toBe("STMT001"); + expect(result[0].iban).toBe("DE89370400440532013000"); + expect(result[0].entries).toEqual([]); + }); + + it("parses credit entries", () => { + const xml = ` + + + + STMT002 + + REF001 + 150.00 + CRDT + +
2024-01-10
+
+ +
2024-01-10
+
+ + + + Payment for invoice 123 + + + + Max Mustermann + + + + DE27100777770209299700 + + + + + E2E-REF-001 + + + +
+
+
+
`; + + const result = parseCamt053(xml); + expect(result.length).toBe(1); + + const entries = result[0].entries; + expect(entries.length).toBe(1); + expect(entries[0].entryReference).toBe("REF001"); + expect(entries[0].amount).toBe(150.0); + expect(entries[0].currency).toBe("EUR"); + expect(entries[0].creditDebitIndicator).toBe("CRDT"); + expect(entries[0].bookingDate).toEqual(new Date("2024-01-10")); + expect(entries[0].valueDate).toEqual(new Date("2024-01-10")); + expect(entries[0].remittanceInformation).toBe("Payment for invoice 123"); + expect(entries[0].counterpartyName).toBe("Max Mustermann"); + expect(entries[0].counterpartyIban).toBe("DE27100777770209299700"); + expect(entries[0].endToEndReference).toBe("E2E-REF-001"); + }); + + it("parses debit entries with negative amount", () => { + const xml = ` + + + + STMT003 + + 75.50 + DBIT + + + + + Online Shop GmbH + + + + DE44500105175407324931 + + + + + + + + + `; + + const result = parseCamt053(xml); + const entry = result[0].entries[0]; + expect(entry.amount).toBe(-75.5); + expect(entry.creditDebitIndicator).toBe("DBIT"); + expect(entry.counterpartyName).toBe("Online Shop GmbH"); + expect(entry.counterpartyIban).toBe("DE44500105175407324931"); + }); + + it("parses balance information", () => { + const xml = ` + + + + STMT004 + + OPBD + 1000.00 + CRDT + + + CLBD + 1150.00 + CRDT + + + + `; + + const result = parseCamt053(xml); + expect(result[0].openingBalance).toBe(1000.0); + expect(result[0].closingBalance).toBe(1150.0); + expect(result[0].currency).toBe("EUR"); + }); + + it("parses debit balance correctly", () => { + const xml = ` + + + + STMT005 + + CLBD + 500.00 + DBIT + + + + `; + + const result = parseCamt053(xml); + expect(result[0].closingBalance).toBe(-500.0); + }); + + it("parses multiple entries", () => { + const xml = ` + + + + STMT006 + + 100.00 + CRDT + + + 50.00 + DBIT + + + 200.00 + CRDT + + + + `; + + const result = parseCamt053(xml); + expect(result[0].entries.length).toBe(3); + expect(result[0].entries[0].amount).toBe(100.0); + expect(result[0].entries[1].amount).toBe(-50.0); + expect(result[0].entries[2].amount).toBe(200.0); + }); + + it("parses bank transaction codes", () => { + const xml = ` + + + + STMT007 + + 100.00 + CRDT + + + PMNT + + + + + + `; + + const result = parseCamt053(xml); + expect(result[0].entries[0].bankTransactionCode).toBe("PMNT"); + }); + + it("handles entry without transaction details", () => { + const xml = ` + + + + STMT008 + + 42.00 + CRDT + + + + `; + + const result = parseCamt053(xml); + const entry = result[0].entries[0]; + expect(entry.amount).toBe(42.0); + expect(entry.counterpartyName).toBeUndefined(); + expect(entry.remittanceInformation).toBeUndefined(); + }); + + it("parses mandate reference", () => { + const xml = ` + + + + STMT009 + + 29.99 + DBIT + + + + MNDT-2024-001 + + + + + + + `; + + const result = parseCamt053(xml); + expect(result[0].entries[0].mandateReference).toBe("MNDT-2024-001"); + }); + + it("parses multiple statements", () => { + const xml = ` + + + + STMT-A + + + STMT-B + + + `; + + const result = parseCamt053(xml); + expect(result.length).toBe(2); + expect(result[0].id).toBe("STMT-A"); + expect(result[1].id).toBe("STMT-B"); + }); + }); + + describe("parseCamt052", () => { + it("returns empty array for empty string", () => { + expect(parseCamt052("")).toEqual([]); + }); + + it("parses a basic camt.052 report", () => { + const xml = ` + + + + RPT001 + + 200.00 + CRDT + + + + `; + + const result = parseCamt052(xml); + expect(result.length).toBe(1); + expect(result[0].id).toBe("RPT001"); + expect(result[0].entries.length).toBe(1); + expect(result[0].entries[0].amount).toBe(200.0); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-client.ts b/packages/fints/src/v4/__tests__/test-client.ts new file mode 100644 index 0000000..e25f2bf --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-client.ts @@ -0,0 +1,227 @@ +import { FinTS4Client } from "../client"; +import { FINTS_NAMESPACE } from "../constants"; + +// Mock fetch for HTTP connection tests +const originalFetch = globalThis.fetch; + +function mockFetch(responses: string[]) { + let callIndex = 0; + globalThis.fetch = jest.fn().mockImplementation(async () => { + if (callIndex >= responses.length) { + throw new Error("No more mock responses available"); + } + const text = responses[callIndex++]; + return { + ok: true, + status: 200, + text: async () => text, + }; + }); +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function buildSyncResponse(): string { + return ` + + 1sync-d1 + + 0010OK + + SyncRes11 + sys-1 + + + BPD12 + Test Bank + + + Balance23 + + + + AccountStatement14 + + + + 1 + `; +} + +function buildOkResponse(dialogId = "d1", extra = ""): string { + return ` + + 1${dialogId} + + 0010OK + ${extra} + + 1 + `; +} + +function buildAccountListResponse(): string { + return buildOkResponse( + "acct-d1", + ` + AccountList13 + + + DE89370400440532013000 + COBADEFFXXX + 0532013000 + 37040044 + Max Mustermann + + + `, + ); +} + +function buildStatementsResponse(): string { + const camtData = `<?xml version="1.0"?><Document><BkToCstmrStmt><Stmt><Id>S1</Id><Ntry><Amt Ccy="EUR">100.00</Amt><CdtDbtInd>CRDT</CdtDbtInd></Ntry></Stmt></BkToCstmrStmt></Document>`; + return buildOkResponse( + "stmt-d1", + ` + AccountStatement13 + + ${camtData} + + `, + ); +} + +const clientConfig = { + blz: "12345678", + name: "testuser", + pin: "12345", + url: "https://banking.example.com/fints", +}; + +describe("FinTS4Client", () => { + describe("createDialog", () => { + it("creates a dialog with the correct config", () => { + const client = new FinTS4Client(clientConfig); + const dialog = client.createDialog(); + + expect(dialog.blz).toBe("12345678"); + expect(dialog.name).toBe("testuser"); + }); + }); + + describe("capabilities", () => { + it("retrieves bank capabilities", async () => { + mockFetch([ + buildSyncResponse(), // sync + buildOkResponse("0"), // end sync dialog + ]); + + const client = new FinTS4Client(clientConfig); + const caps = await client.capabilities(); + + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); + expect(caps.supportsTransactions).toBe(true); + }); + }); + + describe("accounts", () => { + it("fetches account list", async () => { + mockFetch([ + buildSyncResponse(), // sync + buildOkResponse("0"), // end sync dialog + buildOkResponse("init-d1"), // init + buildAccountListResponse(), // account list + buildOkResponse("0"), // end dialog + ]); + + const client = new FinTS4Client(clientConfig); + const accounts = await client.accounts(); + + expect(accounts.length).toBe(1); + expect(accounts[0].iban).toBe("DE89370400440532013000"); + expect(accounts[0].bic).toBe("COBADEFFXXX"); + expect(accounts[0].accountOwnerName).toBe("Max Mustermann"); + }); + }); + + describe("balance", () => { + it("returns default balance when no balance data returned", async () => { + mockFetch([ + buildSyncResponse(), // sync + buildOkResponse("0"), // end sync dialog + buildOkResponse("init-d1"), // init + buildOkResponse("bal-d1"), // balance (no data) + buildOkResponse("0"), // end dialog + ]); + + const client = new FinTS4Client(clientConfig); + const account = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }; + const balance = await client.balance(account); + + expect(balance.account).toEqual(account); + expect(balance.availableBalance).toBe(0); + expect(balance.currency).toBe("EUR"); + }); + }); + + describe("camtStatements", () => { + it("fetches and parses camt statements", async () => { + mockFetch([ + buildSyncResponse(), // sync + buildOkResponse("0"), // end sync dialog + buildOkResponse("init-d1"), // init + buildStatementsResponse(), // statements + buildOkResponse("0"), // end dialog + ]); + + const client = new FinTS4Client(clientConfig); + const account = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }; + const statements = await client.camtStatements(account); + + expect(statements.length).toBe(1); + expect(statements[0].id).toBe("S1"); + expect(statements[0].entries.length).toBe(1); + expect(statements[0].entries[0].amount).toBe(100.0); + }); + }); + + describe("statements", () => { + it("converts camt to Statement format", async () => { + mockFetch([ + buildSyncResponse(), // sync + buildOkResponse("0"), // end sync + buildOkResponse("init-d1"), // init + buildStatementsResponse(), // statements + buildOkResponse("0"), // end dialog + ]); + + const client = new FinTS4Client(clientConfig); + const account = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }; + const statements = await client.statements(account); + + expect(statements.length).toBe(1); + expect(statements[0].referenceNumber).toBe("S1"); + expect(statements[0].transactions.length).toBe(1); + expect(statements[0].transactions[0].amount).toBe(100.0); + expect(statements[0].transactions[0].isCredit).toBe(true); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-connection.ts b/packages/fints/src/v4/__tests__/test-connection.ts new file mode 100644 index 0000000..5978511 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-connection.ts @@ -0,0 +1,120 @@ +import { FinTS4HttpConnection } from "../connection"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("FinTS4HttpConnection", () => { + describe("constructor", () => { + it("initializes with config values", () => { + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + debug: true, + timeout: 5000, + maxRetries: 2, + retryDelay: 500, + }); + + // Connection is created without errors + expect(conn).toBeDefined(); + }); + }); + + describe("send", () => { + it("sends XML request via HTTP POST", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => "d1", + }); + + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + }); + + const result = await conn.send("test"); + + expect(result).toContain(""); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://example.com/fints", + expect.objectContaining({ + method: "POST", + body: "test", + headers: expect.objectContaining({ + "Content-Type": "application/xml; charset=UTF-8", + }), + }), + ); + }); + + it("sends request in debug mode", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => "", + }); + + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + debug: true, + }); + + const result = await conn.send(""); + expect(result).toBe(""); + }); + + it("throws on non-OK HTTP response after retries", async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + maxRetries: 0, + retryDelay: 1, + }); + + await expect(conn.send("")).rejects.toThrow("Received bad status code 500"); + }); + + it("retries on network error", async () => { + let callCount = 0; + globalThis.fetch = jest.fn().mockImplementation(async () => { + callCount++; + if (callCount <= 2) { + throw new Error("Network error"); + } + return { + ok: true, + status: 200, + text: async () => "", + }; + }); + + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + maxRetries: 3, + retryDelay: 1, + }); + + const result = await conn.send(""); + expect(result).toBe(""); + expect(callCount).toBe(3); + }); + + it("throws after max retries exhausted", async () => { + globalThis.fetch = jest.fn().mockRejectedValue(new Error("Persistent failure")); + + const conn = new FinTS4HttpConnection({ + url: "https://example.com/fints", + maxRetries: 1, + retryDelay: 1, + }); + + await expect(conn.send("")).rejects.toThrow("FinTS 4.1: Request failed after 2 attempts"); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-dialog.ts b/packages/fints/src/v4/__tests__/test-dialog.ts new file mode 100644 index 0000000..ec1faef --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-dialog.ts @@ -0,0 +1,365 @@ +import { FinTS4Dialog } from "../dialog"; +import { FinTS4Connection, FinTS4DialogConfig } from "../types"; +import { FINTS_NAMESPACE } from "../constants"; + +/** + * Create a mock connection that returns predefined XML responses. + */ +function createMockConnection(responses: string[]): FinTS4Connection & { calls: string[] } { + let callIndex = 0; + const calls: string[] = []; + return { + calls, + async send(xmlRequest: string): Promise { + calls.push(xmlRequest); + if (callIndex >= responses.length) { + throw new Error("No more mock responses available"); + } + return responses[callIndex++]; + }, + }; +} + +function buildSuccessResponse(dialogId: string, extra = ""): string { + return ` + + + 1 + ${dialogId} + + + + 0010 + Nachricht entgegengenommen + + ${extra} + + 1 + `; +} + +function buildSyncResponse(): string { + return ` + + + 1 + sync-dialog-1 + + + + 0010 + Nachricht entgegengenommen + + + + SyncRes + 1 + 1 + + + system-id-abc + + + + + BPD + 1 + 2 + + + + Test Bank AG + 1 + + + + + + TANMethods + 1 + 3 + + + + 912 + 2 + pushTAN + 6 + + + + + + Balance + 3 + 4 + + + + + + AccountStatement + 2 + 5 + + + + + 1 + `; +} + +function buildErrorResponse(): string { + return ` + + + 1 + err-dialog + + + + 9010 + Verarbeitung nicht möglich + + + `; +} + +const baseConfig: FinTS4DialogConfig = { + blz: "12345678", + name: "testuser", + pin: "12345", + systemId: "0", + productId: "testProduct", +}; + +describe("FinTS4Dialog", () => { + describe("constructor", () => { + it("initializes with config values", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + expect(dialog.blz).toBe("12345678"); + expect(dialog.name).toBe("testuser"); + expect(dialog.pin).toBe("12345"); + expect(dialog.systemId).toBe("0"); + expect(dialog.productId).toBe("testProduct"); + expect(dialog.msgNo).toBe(1); + expect(dialog.dialogId).toBe("0"); + }); + + it("respects provided systemId even if empty string", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog({ ...baseConfig, systemId: "custom-sys" }, conn); + expect(dialog.systemId).toBe("custom-sys"); + }); + }); + + describe("send", () => { + it("sends XML request and parses response", async () => { + const conn = createMockConnection([buildSuccessResponse("d1")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + const response = await dialog.send([{ type: "Test", version: 1, segNo: 1, body: "test" }]); + + expect(response.dialogId).toBe("d1"); + expect(response.success).toBe(true); + expect(conn.calls.length).toBe(1); + }); + + it("updates dialog ID from response", async () => { + const conn = createMockConnection([buildSuccessResponse("new-dialog-id")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }]); + + expect(dialog.dialogId).toBe("new-dialog-id"); + }); + + it("increments message number after successful send", async () => { + const conn = createMockConnection([buildSuccessResponse("d1"), buildSuccessResponse("d1")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + expect(dialog.msgNo).toBe(1); + await dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }]); + expect(dialog.msgNo).toBe(2); + await dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }]); + expect(dialog.msgNo).toBe(3); + }); + + it("throws on error response", async () => { + const conn = createMockConnection([buildErrorResponse()]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await expect(dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }])).rejects.toThrow( + "FinTS 4.1 request failed", + ); + }); + + it("does not update dialogId when response has '0'", async () => { + const response = ` + + 10 + + `; + const conn = createMockConnection([response]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.dialogId = "existing"; + + await dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }]); + + expect(dialog.dialogId).toBe("existing"); + }); + }); + + describe("sync", () => { + it("performs synchronization and updates state", async () => { + const conn = createMockConnection([ + buildSyncResponse(), + buildSuccessResponse("0"), // end dialog + ]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.sync(); + + expect(dialog.systemId).toBe("system-id-abc"); + expect(dialog.bpd).toBeDefined(); + expect(dialog.bpd!.bankName).toBe("Test Bank AG"); + expect(dialog.tanMethods.length).toBe(1); + expect(dialog.tanMethods[0].name).toBe("pushTAN"); + }); + + it("updates security function when no 999 method found", async () => { + const conn = createMockConnection([buildSyncResponse(), buildSuccessResponse("0")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.sync(); + + expect(dialog.securityFunction).toBe("912"); + }); + + it("updates capabilities from segment versions", async () => { + const conn = createMockConnection([buildSyncResponse(), buildSuccessResponse("0")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.sync(); + + expect(dialog.supportsBalance).toBe(true); + expect(dialog.supportsStatements).toBe(true); + expect(dialog.balanceVersion).toBe(3); + expect(dialog.statementVersion).toBe(2); + }); + + it("ends the sync dialog", async () => { + const conn = createMockConnection([buildSyncResponse(), buildSuccessResponse("0")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.sync(); + + // Should have sent 2 messages: sync + end + expect(conn.calls.length).toBe(2); + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + }); + }); + + describe("init", () => { + it("sends dialog initialization", async () => { + const conn = createMockConnection([buildSuccessResponse("init-dialog-1")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + await dialog.init(); + + expect(dialog.dialogId).toBe("init-dialog-1"); + expect(conn.calls.length).toBe(1); + }); + + it("includes TAN segment when TAN methods are available", async () => { + const conn = createMockConnection([buildSuccessResponse("d1")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.tanMethods = [ + { + securityFunction: "912", + tanProcess: "2", + techId: "", + name: "pushTAN", + maxLengthInput: 6, + allowedFormat: "0", + tanListNumberRequired: false, + cancellable: false, + }, + ]; + dialog.tanVersion = 2; + + await dialog.init(); + + const requestXml = conn.calls[0]; + expect(requestXml).toContain("TAN"); + }); + }); + + describe("end", () => { + it("sends dialog end and resets state", async () => { + const conn = createMockConnection([buildSuccessResponse("0")]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.dialogId = "active-dialog"; + dialog.msgNo = 5; + + await dialog.end(); + + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + }); + + it("resets state even on error", async () => { + const conn = createMockConnection([buildErrorResponse()]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.dialogId = "active-dialog"; + + await expect(dialog.end()).rejects.toThrow(); + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + }); + }); + + describe("capabilities", () => { + it("reflects state after sync", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.supportsBalance = true; + dialog.supportsStatements = true; + + const caps = dialog.capabilities; + + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); + expect(caps.supportsTransactions).toBe(true); + expect(caps.supportsHoldings).toBe(false); + expect(caps.supportsStandingOrders).toBe(false); + expect(caps.supportsCreditTransfer).toBe(false); + expect(caps.supportsDirectDebit).toBe(false); + }); + + it("returns false for unsupported features before sync", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog(baseConfig, conn); + + const caps = dialog.capabilities; + + expect(caps.supportsBalance).toBe(false); + expect(caps.supportsTransactions).toBe(false); + }); + + it("reflects TAN requirements", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog(baseConfig, conn); + dialog.statementsMinSignatures = 1; + dialog.balanceMinSignatures = 0; + + const caps = dialog.capabilities; + + expect(caps.requiresTanForTransactions).toBe(true); + expect(caps.requiresTanForBalance).toBe(false); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-mock-bank-integration.ts b/packages/fints/src/v4/__tests__/test-mock-bank-integration.ts new file mode 100644 index 0000000..b461cc9 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-mock-bank-integration.ts @@ -0,0 +1,439 @@ +/** + * Integration tests for FinTS 4.1 using the Mock Bank Server. + * + * These tests exercise the complete client ↔ server interaction + * through a simulated bank server that returns realistic test data. + * Unlike the unit tests, these validate the full request/response cycle + * including XML serialization, dialog management, security, and parsing. + */ +import { FinTS4Dialog } from "../dialog"; +import { MockBankServer } from "../test-server/mock-bank-server"; +import { TEST_BANK, TEST_ACCOUNTS, TEST_TAN_METHODS } from "../test-server/test-data"; +import { parseCamt053 } from "../camt-parser"; +import { buildAccountListSegment, buildBalanceSegment, buildAccountStatementSegment } from "../segments"; + +describe("FinTS 4.1 Integration: Mock Bank Server", () => { + let server: MockBankServer; + let dialog: FinTS4Dialog; + + beforeEach(() => { + server = new MockBankServer(); + dialog = new FinTS4Dialog( + { + blz: TEST_BANK.blz, + name: "testuser", + pin: "12345", + systemId: "0", + productId: "integrationTest", + }, + server.createConnection(), + ); + }); + + afterEach(() => { + server.reset(); + }); + + // ----------------------------------------------------------------------- + // Authentication + // ----------------------------------------------------------------------- + + describe("Authentication", () => { + it("rejects unknown users with 9931 (Zugang gesperrt)", async () => { + const badDialog = new FinTS4Dialog( + { + blz: TEST_BANK.blz, + name: "unknownuser", + pin: "12345", + systemId: "0", + }, + server.createConnection(), + ); + + await expect(badDialog.sync()).rejects.toThrow("9931"); + }); + + it("rejects invalid PIN with 9340 (PIN ungültig)", async () => { + const badDialog = new FinTS4Dialog( + { + blz: TEST_BANK.blz, + name: "testuser", + pin: "wrongpin", + systemId: "0", + }, + server.createConnection(), + ); + + await expect(badDialog.sync()).rejects.toThrow("9340"); + }); + + it("authenticates valid user with correct PIN", async () => { + await dialog.sync(); + expect(dialog.systemId).toBeTruthy(); + expect(dialog.systemId).not.toBe("0"); + }); + }); + + // ----------------------------------------------------------------------- + // Synchronization (§C.1.1) + // ----------------------------------------------------------------------- + + describe("Synchronization", () => { + it("obtains a unique system ID from the server", async () => { + await dialog.sync(); + expect(dialog.systemId).toMatch(/^SYS-\d{10}$/); + }); + + it("receives BPD with bank name and version", async () => { + await dialog.sync(); + expect(dialog.bpd).toBeDefined(); + expect(dialog.bpd!.bankName).toBe(TEST_BANK.bankName); + expect(dialog.bpd!.bpdVersion).toBe(TEST_BANK.bpdVersion); + }); + + it("receives TAN methods from the server", async () => { + await dialog.sync(); + expect(dialog.tanMethods).toHaveLength(TEST_TAN_METHODS.length); + + const pushTan = dialog.tanMethods.find((m) => m.name === "pushTAN 2.0"); + expect(pushTan).toBeDefined(); + expect(pushTan!.securityFunction).toBe("912"); + expect(pushTan!.decoupledMaxStatusRequests).toBe(60); + + const chipTan = dialog.tanMethods.find((m) => m.name === "chipTAN QR"); + expect(chipTan).toBeDefined(); + expect(chipTan!.securityFunction).toBe("913"); + }); + + it("detects bank capabilities from segment parameters", async () => { + await dialog.sync(); + + expect(dialog.supportsBalance).toBe(true); + expect(dialog.balanceVersion).toBe(7); + expect(dialog.supportsStatements).toBe(true); + expect(dialog.statementVersion).toBe(2); + expect(dialog.tanVersion).toBe(7); + }); + + it("updates security function to first TAN method", async () => { + await dialog.sync(); + // Server doesn't offer securityFunction 999 + expect(dialog.securityFunction).toBe("912"); + }); + + it("second user can also synchronize independently", async () => { + const dialog2 = new FinTS4Dialog( + { + blz: TEST_BANK.blz, + name: "testuser2", + pin: "54321", + systemId: "0", + }, + server.createConnection(), + ); + + await dialog.sync(); + await dialog2.sync(); + + // Both get unique system IDs + expect(dialog.systemId).not.toBe(dialog2.systemId); + expect(dialog.systemId).toMatch(/^SYS-/); + expect(dialog2.systemId).toMatch(/^SYS-/); + }); + }); + + // ----------------------------------------------------------------------- + // Full dialog lifecycle (§C.1) + // ----------------------------------------------------------------------- + + describe("Dialog Lifecycle", () => { + it("sync → init → end: complete workflow", async () => { + // 1. Synchronize + await dialog.sync(); + expect(dialog.dialogId).toBe("0"); // sync dialog was ended + + // 2. Initialize new business dialog + await dialog.init(); + expect(dialog.dialogId).toMatch(/^DLG-/); + expect(dialog.msgNo).toBe(2); // after init, msgNo incremented + + // 3. End dialog + await dialog.end(); + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + }); + + it("logs all request/response pairs", async () => { + await dialog.sync(); + + // sync sends: 1 sync request + 1 dialog end = 2 requests + expect(server.requestLog.length).toBe(2); + expect(server.responseLog.length).toBe(2); + + // Request XML should contain FinTSMessage + expect(server.requestLog[0]).toContain(" { + it("retrieves all test accounts", async () => { + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([buildAccountListSegment({ segNo: 3 })]); + + expect(response.accounts).toBeDefined(); + expect(response.accounts!.length).toBe(TEST_ACCOUNTS.length); + + const girokonto = response.accounts!.find((a) => a.iban === "DE89370400440532013000"); + expect(girokonto).toBeDefined(); + expect(girokonto!.bic).toBe("SSKNDE77XXX"); + expect(girokonto!.accountOwnerName).toBe("Max Mustermann"); + + const sparkonto = response.accounts!.find((a) => a.iban === "DE27100777770209299700"); + expect(sparkonto).toBeDefined(); + + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Balance Query (§D.2) + // ----------------------------------------------------------------------- + + describe("Balance Query", () => { + it("retrieves balance for a known account", async () => { + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([ + buildBalanceSegment({ + segNo: 3, + version: dialog.balanceVersion, + account: { + iban: TEST_ACCOUNTS[0].iban, + bic: TEST_ACCOUNTS[0].bic, + accountNumber: TEST_ACCOUNTS[0].accountNumber, + blz: TEST_ACCOUNTS[0].blz, + }, + }), + ]); + + expect(response.success).toBe(true); + await dialog.end(); + }); + + it("rejects balance for unknown account with 9010", async () => { + await dialog.sync(); + await dialog.init(); + + await expect( + dialog.send([ + buildBalanceSegment({ + segNo: 3, + version: 7, + account: { + iban: "DE00000000000000000000", + bic: "UNKNOWN", + accountNumber: "0000000000", + blz: "00000000", + }, + }), + ]), + ).rejects.toThrow("9010"); + + // Dialog is still open; end it + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Account Statements / camt.053 (§D.3) + // ----------------------------------------------------------------------- + + describe("Account Statements (camt.053)", () => { + it("retrieves camt.053 data for a known account", async () => { + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([ + buildAccountStatementSegment({ + segNo: 3, + version: dialog.statementVersion, + account: { + iban: TEST_ACCOUNTS[0].iban, + bic: TEST_ACCOUNTS[0].bic, + accountNumber: TEST_ACCOUNTS[0].accountNumber, + blz: TEST_ACCOUNTS[0].blz, + }, + }), + ]); + + expect(response.camtData).toBeDefined(); + expect(response.camtData!.length).toBeGreaterThan(0); + + await dialog.end(); + }); + + it("camt.053 data can be parsed into structured statements", async () => { + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([ + buildAccountStatementSegment({ + segNo: 3, + version: dialog.statementVersion, + account: { + iban: TEST_ACCOUNTS[0].iban, + bic: TEST_ACCOUNTS[0].bic, + accountNumber: TEST_ACCOUNTS[0].accountNumber, + blz: TEST_ACCOUNTS[0].blz, + }, + }), + ]); + + const statements = parseCamt053(response.camtData!); + expect(statements).toHaveLength(1); + + const stmt = statements[0]; + expect(stmt.iban).toBe(TEST_ACCOUNTS[0].iban); + expect(stmt.openingBalance).toBe(12345.67); + expect(stmt.closingBalance).toBe(13095.67); + expect(stmt.currency).toBe("EUR"); + expect(stmt.entries.length).toBe(5); + + await dialog.end(); + }); + + it("camt.053 contains realistic German transaction data", async () => { + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([ + buildAccountStatementSegment({ + segNo: 3, + version: dialog.statementVersion, + account: { + iban: TEST_ACCOUNTS[0].iban, + bic: TEST_ACCOUNTS[0].bic, + accountNumber: TEST_ACCOUNTS[0].accountNumber, + blz: TEST_ACCOUNTS[0].blz, + }, + }), + ]); + + const statements = parseCamt053(response.camtData!); + const entries = statements[0].entries; + + // Salary (credit) + const salary = entries.find((e) => e.entryReference === "ENT-001"); + expect(salary).toBeDefined(); + expect(salary!.amount).toBe(2500.0); + expect(salary!.creditDebitIndicator).toBe("CRDT"); + expect(salary!.counterpartyName).toBe("Arbeitgeber Test GmbH"); + expect(salary!.counterpartyIban).toBe("DE44500105175407324931"); + expect(salary!.counterpartyBic).toBe("INGDDEFFXXX"); + expect(salary!.endToEndReference).toContain("GEHALT"); + expect(salary!.mandateReference).toBe("MNDT-GEHALT-001"); + + // Rent (debit) + const rent = entries.find((e) => e.entryReference === "ENT-002"); + expect(rent).toBeDefined(); + expect(rent!.amount).toBe(-850.0); + expect(rent!.creditDebitIndicator).toBe("DBIT"); + expect(rent!.counterpartyName).toBe("Immobilien Verwaltung GmbH"); + expect(rent!.endToEndReference).toContain("MIETE"); + expect(rent!.remittanceInformation).toContain("Miete"); + + // Utility (debit with mandate) + const utility = entries.find((e) => e.entryReference === "ENT-003"); + expect(utility).toBeDefined(); + expect(utility!.amount).toBe(-45.99); + expect(utility!.counterpartyName).toBe("Stadtwerke Teststadt"); + expect(utility!.mandateReference).toBe("MNDT-STROM-001"); + + // Freelance income (credit) + const freelance = entries.find((e) => e.entryReference === "ENT-004"); + expect(freelance).toBeDefined(); + expect(freelance!.amount).toBe(1100.0); + expect(freelance!.counterpartyName).toBe("Consulting Kunde AG"); + expect(freelance!.counterpartyBic).toBe("SOLADEST600"); + + // Grocery (debit) + const grocery = entries.find((e) => e.entryReference === "ENT-005"); + expect(grocery).toBeDefined(); + expect(grocery!.amount).toBe(-54.01); + expect(grocery!.counterpartyName).toBe("REWE Markt GmbH"); + expect(grocery!.remittanceInformation).toContain("REWE SAGT DANKE"); + + await dialog.end(); + }); + + it("rejects statement request for unknown account", async () => { + await dialog.sync(); + await dialog.init(); + + await expect( + dialog.send([ + buildAccountStatementSegment({ + segNo: 3, + version: 2, + account: { + iban: "DE00000000000000000000", + bic: "UNKNOWN", + accountNumber: "0000000000", + blz: "00000000", + }, + }), + ]), + ).rejects.toThrow("9010"); + + await dialog.end(); + }); + }); + + // ----------------------------------------------------------------------- + // Server state and logging + // ----------------------------------------------------------------------- + + describe("Server State", () => { + it("reset clears all state and logs", async () => { + await dialog.sync(); + expect(server.requestLog.length).toBeGreaterThan(0); + + server.reset(); + expect(server.requestLog).toHaveLength(0); + expect(server.responseLog).toHaveLength(0); + }); + + it("handles malformed XML gracefully", async () => { + const conn = server.createConnection(); + const response = await conn.send("this is not xml"); + // Server should return an error response + expect(response).toContain("FinTSMessage"); + }); + }); + + // ----------------------------------------------------------------------- + // Bank Capabilities (end-to-end) + // ----------------------------------------------------------------------- + + describe("Bank Capabilities", () => { + it("capabilities reflect read-only support after sync", async () => { + await dialog.sync(); + + const caps = dialog.capabilities; + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); + expect(caps.supportsTransactions).toBe(true); + expect(caps.supportsCreditTransfer).toBe(false); + expect(caps.supportsDirectDebit).toBe(false); + expect(caps.supportsHoldings).toBe(false); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-negotiating-client-v4.ts b/packages/fints/src/v4/__tests__/test-negotiating-client-v4.ts new file mode 100644 index 0000000..2e2ea06 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-negotiating-client-v4.ts @@ -0,0 +1,214 @@ +/** + * Tests for the NegotiatingClient covering the version negotiation + * strategy and all method routing (v3.0 vs v4.1). + */ +import { NegotiatingClient } from "../../negotiating-client"; +import { FINTS_NAMESPACE } from "../constants"; + +// Mock fetch +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function specSyncResponse(): string { + return ` + + 1sync-d1 + + 0010OK + + SyncRes11 + sys-1 + + + BPD12 + Test Bank + + + 1 + `; +} + +function okResponse(dialogId = "d1"): string { + return ` + + 1${dialogId} + + 0010OK + + 1 + `; +} + +function mockFetchForV4(responses: string[]) { + let callIndex = 0; + globalThis.fetch = jest.fn().mockImplementation(async () => { + if (callIndex >= responses.length) { + throw new Error("No more mock responses"); + } + return { + ok: true, + status: 200, + text: async () => responses[callIndex++], + }; + }); +} + +const baseConfig = { + blz: "12345678", + name: "testuser", + pin: "12345", + url: "https://banking.example.com/fints", +}; + +describe("NegotiatingClient: Version negotiation strategy", () => { + it("detects v4.1 when server responds successfully", async () => { + // Mock fetch to return valid v4.1 responses for capabilities check + mockFetchForV4([specSyncResponse(), okResponse("0")]); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const version = await client.detectVersion(); + expect(version).toBe("4.1"); + expect(client.protocolVersion).toBe("4.1"); + }); + + it("falls back to v3.0 when v4.1 sync fails", async () => { + globalThis.fetch = jest.fn().mockRejectedValue(new Error("Connection refused")); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const version = await client.detectVersion(); + expect(version).toBe("3.0"); + }); + + it("routes capabilities() to v4 client when using v4.1", async () => { + mockFetchForV4([ + specSyncResponse(), + okResponse("0"), // detectVersion + specSyncResponse(), + okResponse("0"), // capabilities + ]); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const caps = await client.capabilities(); + expect(caps).toBeDefined(); + expect(caps.supportsAccounts).toBe(true); + }); + + it("routes accounts() to v4 client when using v4.1", async () => { + mockFetchForV4([ + specSyncResponse(), + okResponse("0"), // detectVersion + specSyncResponse(), + okResponse("0"), // sync for accounts + okResponse("init-d1"), // init + ` + + 1acct-d1 + + 0010OK + + AccountList13 + + + DE89370400440532013000 + COBADEFFXXX + 0532013000 + 37040044 + + + + + `, + okResponse("0"), // end + ]); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const accounts = await client.accounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0].iban).toBe("DE89370400440532013000"); + }); + + it("routes balance() to v4 client when using v4.1", async () => { + mockFetchForV4([ + specSyncResponse(), + okResponse("0"), // detectVersion + specSyncResponse(), + okResponse("0"), // sync for balance + okResponse("init-d1"), // init + okResponse("bal-d1"), // balance (no data) + okResponse("0"), // end + ]); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const account = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }; + + const balance = await client.balance(account); + expect(balance).toBeDefined(); + }); + + it("routes statements() to v4 client when using v4.1", async () => { + mockFetchForV4([ + specSyncResponse(), + okResponse("0"), // detectVersion + specSyncResponse(), + okResponse("0"), // sync for statements + okResponse("init-d1"), // init + okResponse("stmt-d1"), // statements (empty) + okResponse("0"), // end + ]); + + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + maxRetries: 0, + retryDelay: 1, + }); + + const account = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }; + + const stmts = await client.statements(account); + expect(stmts).toEqual([]); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-negotiating-client.ts b/packages/fints/src/v4/__tests__/test-negotiating-client.ts new file mode 100644 index 0000000..415488c --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-negotiating-client.ts @@ -0,0 +1,128 @@ +import { NegotiatingClient } from "../../negotiating-client"; +import { FinTS4Client } from "../client"; +import { PinTanClient } from "../../pin-tan-client"; + +// Mock fetch +const originalFetch = globalThis.fetch; + +function mockFetchError() { + globalThis.fetch = jest.fn().mockRejectedValue(new Error("Connection failed")); +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const baseConfig = { + blz: "12345678", + name: "testuser", + pin: "12345", + url: "https://banking.example.com/fints", +}; + +describe("NegotiatingClient", () => { + describe("constructor", () => { + it("creates with default v3.0 preference", () => { + const client = new NegotiatingClient(baseConfig); + expect(client.protocolVersion).toBeNull(); + expect(client.getV3Client()).toBeInstanceOf(PinTanClient); + expect(client.getV4Client()).toBeNull(); + }); + + it("creates v4 client when preferred version is 4.1", () => { + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + }); + expect(client.getV4Client()).toBeInstanceOf(FinTS4Client); + }); + + it("uses v4Url when provided", () => { + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + v4Url: "https://banking.example.com/fints4", + }); + expect(client.getV4Client()).toBeInstanceOf(FinTS4Client); + }); + }); + + describe("detectVersion", () => { + it("defaults to v3.0 when no preferred version", async () => { + const client = new NegotiatingClient(baseConfig); + const version = await client.detectVersion(); + expect(version).toBe("3.0"); + }); + + it("falls back to v3.0 when v4.1 connection fails", async () => { + mockFetchError(); + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + // Use minimal retry settings to avoid timeout + timeout: 100, + }); + + const version = await client.detectVersion(); + expect(version).toBe("3.0"); + }, 30000); + + it("caches the detected version", async () => { + const client = new NegotiatingClient(baseConfig); + + const v1 = await client.detectVersion(); + const v2 = await client.detectVersion(); + expect(v1).toBe(v2); + }); + }); + + describe("forceVersion", () => { + it("forces v3.0", () => { + const client = new NegotiatingClient(baseConfig); + client.forceVersion("3.0"); + expect(client.protocolVersion).toBe("3.0"); + }); + + it("forces v4.1 and creates v4 client if needed", () => { + const client = new NegotiatingClient(baseConfig); + expect(client.getV4Client()).toBeNull(); + + client.forceVersion("4.1"); + expect(client.protocolVersion).toBe("4.1"); + expect(client.getV4Client()).toBeInstanceOf(FinTS4Client); + }); + + it("does not recreate v4 client if already exists", () => { + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + }); + const v4Before = client.getV4Client(); + + client.forceVersion("4.1"); + expect(client.getV4Client()).toBe(v4Before); + }); + }); + + describe("getV3Client", () => { + it("returns the PinTanClient instance", () => { + const client = new NegotiatingClient(baseConfig); + expect(client.getV3Client()).toBeInstanceOf(PinTanClient); + }); + }); + + describe("getV4Client", () => { + it("returns null when v4 is not configured", () => { + const client = new NegotiatingClient(baseConfig); + expect(client.getV4Client()).toBeNull(); + }); + + it("returns FinTS4Client when v4 is configured", () => { + const client = new NegotiatingClient({ + ...baseConfig, + preferredVersion: "4.1", + }); + expect(client.getV4Client()).toBeInstanceOf(FinTS4Client); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-segments.ts b/packages/fints/src/v4/__tests__/test-segments.ts new file mode 100644 index 0000000..56c03f4 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-segments.ts @@ -0,0 +1,261 @@ +import { + buildDialogInitSegment, + buildDialogEndSegment, + buildSyncSegment, + buildAccountListSegment, + buildBalanceSegment, + buildAccountStatementSegment, + buildTanSegment, +} from "../segments"; +import { FINTS_VERSION, COUNTRY_CODE } from "../constants"; +import { SEPAAccount } from "../../types"; + +const testAccount: SEPAAccount = { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", +}; + +describe("v4 segments", () => { + describe("buildDialogInitSegment", () => { + it("creates a DialogInit segment with correct type and version", () => { + const seg = buildDialogInitSegment({ + segNo: 1, + blz: "12345678", + name: "testuser", + systemId: "0", + }); + + expect(seg.type).toBe("DialogInit"); + expect(seg.version).toBe(1); + expect(seg.segNo).toBe(1); + }); + + it("includes BLZ and country code in body", () => { + const seg = buildDialogInitSegment({ + segNo: 1, + blz: "12345678", + name: "user", + systemId: "0", + }); + + expect(seg.body).toContain("12345678"); + expect(seg.body).toContain(`${COUNTRY_CODE}`); + }); + + it("includes customer ID and system ID", () => { + const seg = buildDialogInitSegment({ + segNo: 1, + blz: "12345678", + name: "myuser", + systemId: "sys123", + }); + + expect(seg.body).toContain("myuser"); + expect(seg.body).toContain("sys123"); + }); + + it("includes HBCI version", () => { + const seg = buildDialogInitSegment({ + segNo: 1, + blz: "12345678", + name: "user", + systemId: "0", + }); + + expect(seg.body).toContain(`${FINTS_VERSION}`); + }); + + it("includes custom product ID", () => { + const seg = buildDialogInitSegment({ + segNo: 1, + blz: "12345678", + name: "user", + systemId: "0", + productId: "myApp", + }); + + expect(seg.body).toContain("myApp"); + }); + }); + + describe("buildDialogEndSegment", () => { + it("creates a DialogEnd segment", () => { + const seg = buildDialogEndSegment({ + segNo: 5, + dialogId: "dialog-abc", + }); + + expect(seg.type).toBe("DialogEnd"); + expect(seg.version).toBe(1); + expect(seg.segNo).toBe(5); + expect(seg.body).toContain("dialog-abc"); + }); + }); + + describe("buildSyncSegment", () => { + it("creates a Sync segment with default mode", () => { + const seg = buildSyncSegment({ segNo: 2 }); + + expect(seg.type).toBe("Sync"); + expect(seg.version).toBe(1); + expect(seg.segNo).toBe(2); + expect(seg.body).toContain("0"); + }); + + it("creates a Sync segment with custom mode", () => { + const seg = buildSyncSegment({ segNo: 2, mode: 1 }); + expect(seg.body).toContain("1"); + }); + }); + + describe("buildAccountListSegment", () => { + it("creates an AccountList segment", () => { + const seg = buildAccountListSegment({ segNo: 3 }); + + expect(seg.type).toBe("AccountList"); + expect(seg.version).toBe(1); + expect(seg.segNo).toBe(3); + expect(seg.body).toContain("true"); + }); + }); + + describe("buildBalanceSegment", () => { + it("creates a Balance segment with account info", () => { + const seg = buildBalanceSegment({ + segNo: 3, + version: 6, + account: testAccount, + }); + + expect(seg.type).toBe("Balance"); + expect(seg.version).toBe(6); + expect(seg.segNo).toBe(3); + expect(seg.body).toContain("DE89370400440532013000"); + expect(seg.body).toContain("COBADEFFXXX"); + expect(seg.body).toContain("0532013000"); + expect(seg.body).toContain("37040044"); + }); + }); + + describe("buildAccountStatementSegment", () => { + it("creates an AccountStatement segment", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 1, + account: testAccount, + }); + + expect(seg.type).toBe("AccountStatement"); + expect(seg.version).toBe(1); + expect(seg.body).toContain("DE89370400440532013000"); + expect(seg.body).toContain("urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"); + }); + + it("includes date range when specified", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 1, + account: testAccount, + startDate: new Date("2024-01-01"), + endDate: new Date("2024-01-31"), + }); + + expect(seg.body).toContain("2024-01-01"); + expect(seg.body).toContain("2024-01-31"); + }); + + it("includes custom camt format", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 1, + account: testAccount, + camtFormat: "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02", + }); + + expect(seg.body).toContain("camt.052.001.02"); + }); + + it("includes touchdown token for pagination", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 1, + account: testAccount, + touchdown: "touch-token-123", + }); + + expect(seg.body).toContain("touch-token-123"); + }); + + it("omits dates when not specified", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 1, + account: testAccount, + }); + + expect(seg.body).not.toContain(""); + expect(seg.body).not.toContain(""); + }); + }); + + describe("buildTanSegment", () => { + it("creates a TAN segment with process", () => { + const seg = buildTanSegment({ + segNo: 4, + version: 1, + process: "4", + }); + + expect(seg.type).toBe("TAN"); + expect(seg.version).toBe(1); + expect(seg.body).toContain("4"); + }); + + it("includes segment reference", () => { + const seg = buildTanSegment({ + segNo: 4, + version: 1, + process: "4", + segmentReference: "AccountStatement", + }); + + expect(seg.body).toContain("AccountStatement"); + }); + + it("includes TAN medium", () => { + const seg = buildTanSegment({ + segNo: 4, + version: 1, + process: "4", + medium: "pushTAN", + }); + + expect(seg.body).toContain("pushTAN"); + }); + + it("includes transaction reference", () => { + const seg = buildTanSegment({ + segNo: 4, + version: 1, + process: "2", + aref: "txref-123", + }); + + expect(seg.body).toContain("txref-123"); + }); + + it("omits optional fields when not provided", () => { + const seg = buildTanSegment({ + segNo: 4, + version: 1, + process: "4", + }); + + expect(seg.body).not.toContain(""); + expect(seg.body).not.toContain(""); + expect(seg.body).not.toContain(""); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-spec-conformance.ts b/packages/fints/src/v4/__tests__/test-spec-conformance.ts new file mode 100644 index 0000000..c982fb4 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-spec-conformance.ts @@ -0,0 +1,942 @@ +/** + * FinTS 4.1 Specification Conformance Tests + * + * These tests validate our implementation against the FinTS 4.1 specification + * (FinTS_4.1_Master_2018-11-29_final_version.pdf). + * + * The tests use realistic XML messages that follow the exact structure + * defined in the specification, including: + * + * - Correct XML namespace (urn:org:fints:4.1) + * - Message envelope structure (MsgHead, MsgBody, MsgTail) + * - Security profile (PIN/TAN with SignatureHeader/SignatureTrailer) + * - Return codes as defined in the specification + * - Complete dialog workflows (Sync → Init → BusinessTransaction → End) + * - camt.053 (ISO 20022) compliant account statement format + * - BPD/UPD parameter data structures + * - TAN method negotiation per specification + */ +import { FINTS_NAMESPACE, FINTS_VERSION, COUNTRY_CODE } from "../constants"; +import { PRODUCT_VERSION } from "../../constants"; +import { buildMessage } from "../xml-builder"; +import { parseResponse, isFinTS4Response, isErrorCode } from "../xml-parser"; +import { parseCamt053 } from "../camt-parser"; +import { FinTS4Dialog } from "../dialog"; +import { + buildDialogInitSegment, + buildAccountListSegment, + buildAccountStatementSegment, + buildTanSegment, +} from "../segments"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockConnection(responses: string[]): { send: jest.Mock; calls: string[] } { + let idx = 0; + const calls: string[] = []; + return { + calls, + send: jest.fn(async (xml: string) => { + calls.push(xml); + if (idx >= responses.length) throw new Error("No more mock responses"); + return responses[idx++]; + }), + }; +} + +const dialogConfig = { + blz: "76050101", + name: "testkundenid", + pin: "12345", + systemId: "0", + productId: "fintsTestProduct", +}; + +// --------------------------------------------------------------------------- +// §B.5 – FinTS 4.1 Return Codes (Rückmeldungen) +// The specification defines specific return codes for different situations. +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Return Codes (§B.5)", () => { + describe("Informational codes (0xxx)", () => { + it("0010 – Nachricht entgegengenommen (message accepted)", () => { + expect(isErrorCode("0010")).toBe(false); + }); + + it("0020 – Auftrag ausgeführt (order executed)", () => { + expect(isErrorCode("0020")).toBe(false); + }); + + it("0100 – Dialog beendet (dialog ended)", () => { + expect(isErrorCode("0100")).toBe(false); + }); + }); + + describe("Warning codes (3xxx)", () => { + it("3010 – Es liegen weitere Informationen vor (more information available)", () => { + expect(isErrorCode("3010")).toBe(false); + }); + + it("3040 – Auftragsbezogene Rückmeldung: Touchdown (pagination token)", () => { + expect(isErrorCode("3040")).toBe(false); + }); + + it("3920 – Zugelassene TAN-Verfahren für den Benutzer (allowed TAN methods)", () => { + expect(isErrorCode("3920")).toBe(false); + }); + + it("3076 – Starke Kundenauthentifizierung erforderlich (SCA required)", () => { + expect(isErrorCode("3076")).toBe(false); + }); + + it("3956 – Decoupled TAN pending", () => { + expect(isErrorCode("3956")).toBe(false); + }); + }); + + describe("Error codes (9xxx)", () => { + it("9010 – Verarbeitung nicht möglich (processing not possible)", () => { + expect(isErrorCode("9010")).toBe(true); + }); + + it("9050 – Teilweise fehlerhaft (partially erroneous)", () => { + expect(isErrorCode("9050")).toBe(true); + }); + + it("9340 – PIN ungültig (invalid PIN)", () => { + expect(isErrorCode("9340")).toBe(true); + }); + + it("9800 – Dialog wurde nicht korrekt beendet (dialog not correctly ended)", () => { + expect(isErrorCode("9800")).toBe(true); + }); + + it("9931 – Zugang gesperrt (access blocked)", () => { + expect(isErrorCode("9931")).toBe(true); + }); + + it("9942 – PIN gesperrt (PIN blocked)", () => { + expect(isErrorCode("9942")).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §B.3 – FinTS 4.1 Message Envelope Structure +// The specification defines the XML message envelope with MsgHead, MsgBody, MsgTail. +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Message Envelope (§B.3)", () => { + it("produces a well-formed XML message with correct namespace", () => { + const msg = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [buildDialogInitSegment({ segNo: 1, blz: "76050101", name: "test", systemId: "0" })], + blz: "76050101", + name: "test", + pin: "12345", + systemId: "0", + }); + + // Must start with XML declaration + expect(msg).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/); + // Must use FinTS 4.1 namespace + expect(msg).toContain(`xmlns="${FINTS_NAMESPACE}"`); + // Must contain all three envelope parts + expect(msg).toContain(""); + expect(msg).toContain(""); + expect(msg).toContain(""); + }); + + it("MsgHead contains correct fields per specification", () => { + const msg = buildMessage({ + msgNo: 3, + dialogId: "dialog-42", + segments: [], + blz: "76050101", + name: "kundenid", + pin: "secret", + systemId: "sys-abc", + productId: "myProd", + }); + + // §B.3.1 – MsgNo: Nachrichtennummer + expect(msg).toContain("3"); + // §B.3.1 – DialogID: Eindeutige Dialog-Kennung + expect(msg).toContain("dialog-42"); + // §B.3.1 – HBCIVersion: Protokollversion + expect(msg).toContain(`${FINTS_VERSION}`); + // §B.3.1 – Initiator: Kreditinstitut (BLZ) und Landeskennzeichen + expect(msg).toContain("76050101"); + expect(msg).toContain(`${COUNTRY_CODE}`); + // §B.3.1 – SystemID + expect(msg).toContain("sys-abc"); + // §B.3.1 – Produktkennung + expect(msg).toContain("myProd"); + expect(msg).toContain(`${PRODUCT_VERSION}`); + }); + + it("MsgTail contains message number matching MsgHead", () => { + const msg = buildMessage({ + msgNo: 7, + dialogId: "0", + segments: [], + blz: "12345678", + name: "user", + pin: "pin", + systemId: "0", + }); + + // §B.3.3 – MsgTail must repeat the message number + expect(msg).toContain("7"); + }); +}); + +// --------------------------------------------------------------------------- +// §B.4 – FinTS 4.1 Security Profile (PIN/TAN) +// The specification defines how PIN/TAN authentication works in v4.1. +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Security Profile PIN/TAN (§B.4)", () => { + it("embeds SignatureHeader with security function and method", () => { + const msg = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [], + blz: "12345678", + name: "kundenid", + pin: "geheim", + systemId: "0", + securityFunction: "912", + }); + + // §B.4.1 – Sicherheitsfunktion + expect(msg).toContain("912"); + // §B.4.1 – Sicherheitsverfahren + expect(msg).toContain("PIN_TAN"); + // §B.4.1 – Benutzerkennung + expect(msg).toContain("kundenid"); + }); + + it("embeds PIN in SignatureTrailer", () => { + const msg = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [], + blz: "12345678", + name: "user", + pin: "mySecretPIN", + systemId: "0", + }); + + // §B.4.2 – PIN im Signaturabschluss + expect(msg).toContain(""); + expect(msg).toContain("mySecretPIN"); + }); + + it("embeds TAN when provided for 2-factor auth", () => { + const msg = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [], + blz: "12345678", + name: "user", + pin: "pin", + systemId: "0", + tan: "654321", + }); + + // §B.4.2 – TAN im Signaturabschluss + expect(msg).toContain("654321"); + }); + + it("SignatureHeader precedes segments, SignatureTrailer follows", () => { + const msg = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [{ type: "TestSeg", version: 1, segNo: 1, body: "1" }], + blz: "12345678", + name: "user", + pin: "pin", + systemId: "0", + }); + + const headerIdx = msg.indexOf(""); + const segIdx = msg.indexOf("TestSeg"); + const trailerIdx = msg.indexOf(""); + + // Order: Header < Segment < Trailer + expect(headerIdx).toBeLessThan(segIdx); + expect(segIdx).toBeLessThan(trailerIdx); + }); +}); + +// --------------------------------------------------------------------------- +// §C.1 – FinTS 4.1 Dialog Lifecycle +// The specification defines the exact sequence: +// Synchronisation → DialogInitialisierung → Geschäftsvorfall(e) → Dialogende +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Dialog Lifecycle (§C.1)", () => { + /** + * Complete synchronization response per spec: + * - Return code 0010 (Nachricht entgegengenommen) + * - SyncRes with SystemID + * - BPD with bank parameters + * - TANMethods with available TAN methods + * - Parameter segments advertising capabilities + */ + function specSyncResponse(): string { + return ` + + + 1 + sync-4711 + ${FINTS_VERSION} + + + + 0010 + Nachricht entgegengenommen + + + 3920 + Zugelassene Ein- und Zwei-Schritt-Verfahren für den Benutzer + 912 + 913 + + + SyncRes11 + 0000000001 + + + BPD12 + + + Sparkasse Nürnberg + 85 + 4.1 + de + PIN_TAN + urn:iso:std:iso:20022:tech:xsd:pain.001.003.03 + urn:iso:std:iso:20022:tech:xsd:camt.053.001.02 + urn:iso:std:iso:20022:tech:xsd:camt.052.001.02 + + + + + TANMethods73 + + + 912 + 2 + pushTAN + pushTAN 2.0 + 6 + 1 + true + 60 + 5 + 2 + + + 913 + 1 + chipTAN + chipTAN QR + 8 + 0 + false + + + + + Balance74 + + + + AccountStatement25 + + + + TAN76 + + + + 1 + `; + } + + function specSuccessResponse(dialogId: string): string { + return ` + + 1${dialogId} + + 0010Nachricht entgegengenommen + + 1 + `; + } + + function specDialogEndResponse(): string { + return ` + + 10 + + 0100Dialog beendet + + 1 + `; + } + + it("§C.1.1 – Sync dialog: obtains SystemID, BPD, TAN methods", async () => { + const conn = createMockConnection([specSyncResponse(), specDialogEndResponse()]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + + await dialog.sync(); + + // SystemID assigned by server + expect(dialog.systemId).toBe("0000000001"); + + // BPD parsed + expect(dialog.bpd).toBeDefined(); + expect(dialog.bpd!.bankName).toBe("Sparkasse Nürnberg"); + expect(dialog.bpd!.bpdVersion).toBe(85); + + // TAN methods parsed (both pushTAN and chipTAN) + expect(dialog.tanMethods).toHaveLength(2); + expect(dialog.tanMethods[0].securityFunction).toBe("912"); + expect(dialog.tanMethods[0].name).toBe("pushTAN 2.0"); + expect(dialog.tanMethods[0].decoupledMaxStatusRequests).toBe(60); + expect(dialog.tanMethods[1].securityFunction).toBe("913"); + expect(dialog.tanMethods[1].name).toBe("chipTAN QR"); + }); + + it("§C.1.1 – Sync dialog detects bank capabilities from parameter segments", async () => { + const conn = createMockConnection([specSyncResponse(), specDialogEndResponse()]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + + await dialog.sync(); + + // Capabilities derived from segment versions + expect(dialog.supportsBalance).toBe(true); + expect(dialog.balanceVersion).toBe(7); + expect(dialog.supportsStatements).toBe(true); + expect(dialog.statementVersion).toBe(2); + expect(dialog.tanVersion).toBe(7); + }); + + it("§C.1.1 – Sync negotiates security function from TAN methods", async () => { + const conn = createMockConnection([specSyncResponse(), specDialogEndResponse()]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + + await dialog.sync(); + + // Should use first TAN method's security function (no 999 method available) + expect(dialog.securityFunction).toBe("912"); + }); + + it("§C.1.2 – Full dialog lifecycle: sync → init → business → end", async () => { + const conn = createMockConnection([ + specSyncResponse(), // 1. sync + specDialogEndResponse(), // 2. end sync dialog + specSuccessResponse("init-d1"), // 3. init new dialog + specSuccessResponse("init-d1"), // 4. business transaction + specDialogEndResponse(), // 5. end business dialog + ]); + + const dialog = new FinTS4Dialog(dialogConfig, conn); + + // Step 1: Synchronization (assigns SystemID, reads BPD) + await dialog.sync(); + expect(dialog.systemId).toBe("0000000001"); + + // Step 2: Initialize business dialog + await dialog.init(); + expect(dialog.dialogId).toBe("init-d1"); + + // Step 3: Send business transaction (e.g., account list) + const response = await dialog.send([buildAccountListSegment({ segNo: 3 })]); + expect(response.success).toBe(true); + + // Step 4: End dialog + await dialog.end(); + expect(dialog.dialogId).toBe("0"); + expect(dialog.msgNo).toBe(1); + + // Verify 5 messages were sent + expect(conn.calls.length).toBe(5); + }); + + it("§C.1.3 – DialogInit request contains required segments", async () => { + const conn = createMockConnection([specSuccessResponse("d1")]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + dialog.tanMethods = [ + { + securityFunction: "912", + tanProcess: "2", + techId: "pushTAN", + name: "pushTAN 2.0", + maxLengthInput: 6, + allowedFormat: "1", + tanListNumberRequired: false, + cancellable: true, + }, + ]; + dialog.tanVersion = 7; + + await dialog.init(); + + const requestXml = conn.calls[0]; + // Must contain DialogInit segment + expect(requestXml).toContain("DialogInit"); + // Must contain TAN segment for SCA (§B.4) + expect(requestXml).toContain("TAN"); + // Must contain security envelope + expect(requestXml).toContain(""); + expect(requestXml).toContain(""); + }); + + it("§C.1.4 – DialogEnd request contains DialogID", async () => { + const conn = createMockConnection([specDialogEndResponse()]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + dialog.dialogId = "dialog-to-end"; + + await dialog.end(); + + const requestXml = conn.calls[0]; + expect(requestXml).toContain("DialogEnd"); + expect(requestXml).toContain("dialog-to-end"); + }); + + it("§C.1.5 – Error response (9010) causes dialog to fail", async () => { + const errorResponse = ` + + 1err-d1 + + + 9010 + Verarbeitung nicht möglich + + + `; + + const conn = createMockConnection([errorResponse]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + + await expect(dialog.send([buildAccountListSegment({ segNo: 1 })])).rejects.toThrow( + "FinTS 4.1 request failed: 9010: Verarbeitung nicht möglich", + ); + }); + + it("§C.1.5 – Multiple error codes in response are all reported", async () => { + const multiErrorResponse = ` + + 1err-d1 + + + 9010 + Verarbeitung nicht möglich + + + 9340 + PIN ungültig + + + `; + + const conn = createMockConnection([multiErrorResponse]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + + await expect(dialog.send([{ type: "Test", version: 1, segNo: 1, body: "" }])).rejects.toThrow(/9010.*9340/); + }); +}); + +// --------------------------------------------------------------------------- +// §D.3 – FinTS 4.1 Account Statement (HKCAZ / camt.053) +// The specification uses ISO 20022 camt.053.001.02 for account statements. +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Account Statements / camt.053 (§D.3)", () => { + /** + * A realistic camt.053.001.02 document as returned by a German bank, + * following the ISO 20022 standard. + */ + const realisticCamt053 = ` + + + + MSG-2024-01-15-001 + 2024-01-15T23:59:00+01:00 + + + 2024-01-15-001 + 42 + 2024-01-15T23:59:00+01:00 + + DE89370400440532013000 + EUR + + + COBADEFFXXX + + + + + PRCD + 5432.10 + CRDT +
2024-01-14
+
+ + CLBD + 5782.10 + CRDT +
2024-01-15
+
+ + 2024011500001 + 500.00 + CRDT + BOOK +
2024-01-15
+
2024-01-15
+ + + PMNT + + RCDT + ESCT + + + + + + + SALARY-2024-01 + MNDT-SALARY-001 + + + + Arbeitgeber GmbH + + + DE44500105175407324931 + + + + + + INGDDEFFXXX + + + + + Gehalt Januar 2024 + + + +
+ + 2024011500002 + 150.00 + DBIT + BOOK +
2024-01-15
+
2024-01-15
+ + + PMNT + + ICDT + ESCT + + + + + + + RENT-2024-01 + + + + Vermieter Immobilien AG + + + DE27100777770209299700 + + + + Miete Januar 2024 Wohnung 4B + + + +
+
+
+
`; + + it("parses ISO 20022 camt.053.001.02 statement with balances", () => { + const stmts = parseCamt053(realisticCamt053); + + expect(stmts).toHaveLength(1); + const stmt = stmts[0]; + expect(stmt.id).toBe("2024-01-15-001"); + expect(stmt.iban).toBe("DE89370400440532013000"); + + // Opening balance (PRCD = Previous Closing Date balance) + expect(stmt.openingBalance).toBe(5432.1); + // Closing balance + expect(stmt.closingBalance).toBe(5782.1); + expect(stmt.currency).toBe("EUR"); + }); + + it("correctly parses credit entry (Gutschrift / Gehaltseingang)", () => { + const stmts = parseCamt053(realisticCamt053); + const entry = stmts[0].entries[0]; + + expect(entry.entryReference).toBe("2024011500001"); + expect(entry.amount).toBe(500.0); // Positive for credit + expect(entry.currency).toBe("EUR"); + expect(entry.creditDebitIndicator).toBe("CRDT"); + expect(entry.bookingDate).toEqual(new Date("2024-01-15")); + expect(entry.valueDate).toEqual(new Date("2024-01-15")); + expect(entry.bankTransactionCode).toBe("PMNT"); + expect(entry.counterpartyName).toBe("Arbeitgeber GmbH"); + expect(entry.counterpartyIban).toBe("DE44500105175407324931"); + expect(entry.counterpartyBic).toBe("INGDDEFFXXX"); + expect(entry.endToEndReference).toBe("SALARY-2024-01"); + expect(entry.mandateReference).toBe("MNDT-SALARY-001"); + expect(entry.remittanceInformation).toBe("Gehalt Januar 2024"); + }); + + it("correctly parses debit entry (Lastschrift / Miete)", () => { + const stmts = parseCamt053(realisticCamt053); + const entry = stmts[0].entries[1]; + + expect(entry.entryReference).toBe("2024011500002"); + expect(entry.amount).toBe(-150.0); // Negative for debit + expect(entry.creditDebitIndicator).toBe("DBIT"); + expect(entry.counterpartyName).toBe("Vermieter Immobilien AG"); + expect(entry.counterpartyIban).toBe("DE27100777770209299700"); + expect(entry.endToEndReference).toBe("RENT-2024-01"); + expect(entry.remittanceInformation).toBe("Miete Januar 2024 Wohnung 4B"); + }); + + it("§D.3.1 – AccountStatement segment builder includes camt format URN", () => { + const seg = buildAccountStatementSegment({ + segNo: 3, + version: 2, + account: { + iban: "DE89370400440532013000", + bic: "COBADEFFXXX", + accountNumber: "0532013000", + blz: "37040044", + }, + startDate: new Date("2024-01-01"), + endDate: new Date("2024-01-31"), + }); + + expect(seg.type).toBe("AccountStatement"); + expect(seg.version).toBe(2); + // Must include the camt.053 URN as format identifier + expect(seg.body).toContain("urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"); + expect(seg.body).toContain("2024-01-01"); + expect(seg.body).toContain("2024-01-31"); + }); +}); + +// --------------------------------------------------------------------------- +// §D.2 – FinTS 4.1 TAN Segment & Method Negotiation +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: TAN Method Negotiation (§D.2)", () => { + it("parses decoupled TAN method parameters", () => { + const xml = ` + + 1d1 + + + TANMethods71 + + + 912 + 2 + pushTAN + pushTAN 2.0 + 6 + 1 + false + true + 60 + 5 + 2 + + + + + `; + + const response = parseResponse(xml); + + expect(response.tanMethods).toHaveLength(1); + const method = response.tanMethods![0]; + expect(method.securityFunction).toBe("912"); + expect(method.tanProcess).toBe("2"); + expect(method.name).toBe("pushTAN 2.0"); + expect(method.maxLengthInput).toBe(6); + expect(method.cancellable).toBe(true); + // Decoupled parameters per FinTS 3.0+ PINTAN spec + expect(method.decoupledMaxStatusRequests).toBe(60); + expect(method.decoupledWaitBeforeFirstStatusRequest).toBe(5); + expect(method.decoupledWaitBetweenStatusRequests).toBe(2); + }); + + it("TAN segment builder supports process='4' for SCA pre-dialog", () => { + const seg = buildTanSegment({ segNo: 5, version: 7, process: "4" }); + expect(seg.body).toContain("4"); + }); + + it("TAN segment builder supports process='2' for decoupled polling", () => { + const seg = buildTanSegment({ + segNo: 5, + version: 7, + process: "2", + aref: "TX-REF-4711", + }); + expect(seg.body).toContain("2"); + expect(seg.body).toContain("TX-REF-4711"); + }); +}); + +// --------------------------------------------------------------------------- +// §B.6 – Response Parsing for various server response scenarios +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Response Parsing (§B.6)", () => { + it("isFinTS4Response correctly identifies XML vs. v3.0 format", () => { + // FinTS 4.1 XML response + expect(isFinTS4Response('')).toBe(true); + expect(isFinTS4Response("")).toBe(true); + + // FinTS 3.0 proprietary format (starts with HNHBK segment) + expect(isFinTS4Response("HNHBK:1:3+000000000152+300+0+1'")).toBe(false); + // Base64-encoded (typical v3.0 HTTP response) + expect(isFinTS4Response("SE5IQks6MTozKzAwMD")).toBe(false); + }); + + it("parses response with UPD (User Parameter Data)", () => { + const xml = ` + + 1d1 + + + UPD11 + + + 3 + + DE89370400440532013000 + COBADEFFXXX + Max Mustermann + Girokonto + HKCAZ + HKSAL + + + + + + `; + + const response = parseResponse(xml); + expect(response.upd).toBeDefined(); + expect(response.upd!.updVersion).toBe(3); + expect(response.upd!.accounts).toHaveLength(1); + expect(response.upd!.accounts![0].iban).toBe("DE89370400440532013000"); + expect(response.upd!.accounts![0].ownerName).toBe("Max Mustermann"); + expect(response.upd!.accounts![0].allowedTransactions).toEqual(["HKCAZ", "HKSAL"]); + }); + + it("parses BPD with segment version capabilities", () => { + const xml = ` + + 1d1 + + + BPD11 + + + Deutsche Bank + 100 + HICAZS2 + HISALS7 + HITANS7 + + + + + `; + + const response = parseResponse(xml); + expect(response.bpd).toBeDefined(); + expect(response.bpd!.bankName).toBe("Deutsche Bank"); + + // Segment versions from BPD are propagated to response + expect(response.segmentVersions!.get("HICAZS")).toBe(2); + expect(response.segmentVersions!.get("HISALS")).toBe(7); + expect(response.segmentVersions!.get("HITANS")).toBe(7); + }); + + it("parses touchdown for paginated statement responses", () => { + const xml = ` + + 1d1 + + + 3040 + Es liegen weitere Informationen vor + ABCDEF1234567890 + + + AccountStatement23 + + first-page-camt-data + + + + `; + + const response = parseResponse(xml); + expect(response.touchdown).toBe("ABCDEF1234567890"); + expect(response.camtData).toBe("first-page-camt-data"); + }); +}); + +// --------------------------------------------------------------------------- +// §E – FinTS 4.1 Bank Capabilities (derived from BPD) +// --------------------------------------------------------------------------- + +describe("FinTS 4.1 Specification: Bank Capabilities (§E)", () => { + it("capabilities reflect read-only support (no credit transfer, no direct debit)", () => { + const conn = createMockConnection([]); + const dialog = new FinTS4Dialog(dialogConfig, conn); + dialog.supportsBalance = true; + dialog.supportsStatements = true; + + const caps = dialog.capabilities; + + // Read-only operations + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); + expect(caps.supportsTransactions).toBe(true); + + // Not yet implemented in v4.1 client + expect(caps.supportsHoldings).toBe(false); + expect(caps.supportsStandingOrders).toBe(false); + expect(caps.supportsCreditTransfer).toBe(false); + expect(caps.supportsDirectDebit).toBe(false); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-xml-builder.ts b/packages/fints/src/v4/__tests__/test-xml-builder.ts new file mode 100644 index 0000000..893ecc4 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-xml-builder.ts @@ -0,0 +1,305 @@ +import { + escapeXml, + xmlElement, + xmlEmptyElement, + buildMsgHead, + buildMsgTail, + buildSecurityEnvelope, + buildSegment, + buildMessage, + XmlSegment, +} from "../xml-builder"; +import { FINTS_NAMESPACE, FINTS_VERSION, COUNTRY_CODE } from "../constants"; +import { PRODUCT_NAME, PRODUCT_VERSION } from "../../constants"; + +describe("xml-builder", () => { + describe("escapeXml", () => { + it("escapes ampersands", () => { + expect(escapeXml("foo & bar")).toBe("foo & bar"); + }); + + it("escapes angle brackets", () => { + expect(escapeXml("")).toBe("<tag>"); + }); + + it("escapes quotes", () => { + expect(escapeXml('"hello"')).toBe(""hello""); + }); + + it("escapes apostrophes", () => { + expect(escapeXml("it's")).toBe("it's"); + }); + + it("handles empty string", () => { + expect(escapeXml("")).toBe(""); + }); + + it("handles string with no special characters", () => { + expect(escapeXml("hello world")).toBe("hello world"); + }); + + it("escapes multiple special characters", () => { + expect(escapeXml("a & b < c > \"d\" & 'e'")).toBe( + "a & b < c > "d" & 'e'", + ); + }); + }); + + describe("xmlElement", () => { + it("creates a simple element", () => { + expect(xmlElement("Name", "value")).toBe("value"); + }); + + it("creates an element with attributes", () => { + const result = xmlElement("Tag", "content", { attr1: "val1", attr2: "val2" }); + expect(result).toBe('content'); + }); + + it("escapes attribute values", () => { + const result = xmlElement("Tag", "content", { name: 'say "hi"' }); + expect(result).toBe('content'); + }); + + it("creates nested elements", () => { + const inner = xmlElement("Inner", "text"); + const outer = xmlElement("Outer", inner); + expect(outer).toBe("text"); + }); + }); + + describe("xmlEmptyElement", () => { + it("creates a self-closing element", () => { + expect(xmlEmptyElement("Break")).toBe(""); + }); + + it("creates a self-closing element with attributes", () => { + expect(xmlEmptyElement("Input", { type: "text" })).toBe(''); + }); + }); + + describe("buildMsgHead", () => { + it("builds a valid message header", () => { + const result = buildMsgHead({ + msgNo: 1, + dialogId: "0", + blz: "12345678", + systemId: "0", + }); + + expect(result).toContain(""); + expect(result).toContain("1"); + expect(result).toContain("0"); + expect(result).toContain(`${FINTS_VERSION}`); + expect(result).toContain("12345678"); + expect(result).toContain(`${COUNTRY_CODE}`); + expect(result).toContain("0"); + expect(result).toContain(""); + }); + + it("includes product information", () => { + const result = buildMsgHead({ + msgNo: 1, + dialogId: "0", + blz: "12345678", + systemId: "0", + productId: "myProduct", + }); + + expect(result).toContain("myProduct"); + expect(result).toContain(`${PRODUCT_VERSION}`); + }); + + it("uses default product name when not specified", () => { + const result = buildMsgHead({ + msgNo: 1, + dialogId: "0", + blz: "12345678", + systemId: "0", + }); + + expect(result).toContain(`${PRODUCT_NAME}`); + }); + + it("escapes special characters in dialogId", () => { + const result = buildMsgHead({ + msgNo: 1, + dialogId: "id&special", + blz: "12345678", + systemId: "0", + }); + + expect(result).toContain("id&special"); + }); + }); + + describe("buildMsgTail", () => { + it("builds a valid message tail", () => { + const result = buildMsgTail(1); + expect(result).toBe("1"); + }); + + it("works with larger message numbers", () => { + const result = buildMsgTail(42); + expect(result).toBe("42"); + }); + }); + + describe("buildSecurityEnvelope", () => { + it("builds header and trailer with PIN", () => { + const result = buildSecurityEnvelope({ + pin: "12345", + name: "testuser", + systemId: "sys1", + }); + + expect(result.header).toContain(""); + expect(result.header).toContain("999"); + expect(result.header).toContain("PIN_TAN"); + expect(result.header).toContain("testuser"); + expect(result.header).toContain("sys1"); + + expect(result.trailer).toContain(""); + expect(result.trailer).toContain("12345"); + }); + + it("builds trailer with TAN", () => { + const result = buildSecurityEnvelope({ + pin: "12345", + tan: "678901", + name: "user", + systemId: "0", + }); + + expect(result.trailer).toContain("12345"); + expect(result.trailer).toContain("678901"); + }); + + it("builds empty trailer without PIN or TAN", () => { + const result = buildSecurityEnvelope({ + name: "user", + systemId: "0", + }); + + expect(result.trailer).toBe(""); + }); + + it("uses custom security function", () => { + const result = buildSecurityEnvelope({ + name: "user", + systemId: "0", + securityFunction: "912", + }); + + expect(result.header).toContain("912"); + }); + + it("escapes special characters in credentials", () => { + const result = buildSecurityEnvelope({ + pin: "pass&word", + name: "user", + systemId: "0", + }); + + expect(result.trailer).toContain("pass&word"); + expect(result.header).toContain("user<name>"); + }); + }); + + describe("buildSegment", () => { + it("builds a segment with header and body", () => { + const segment: XmlSegment = { + type: "TestSeg", + version: 2, + segNo: 3, + body: "value", + }; + + const result = buildSegment(segment); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain("TestSeg"); + expect(result).toContain("2"); + expect(result).toContain("3"); + expect(result).toContain("value"); + expect(result).toContain(""); + }); + }); + + describe("buildMessage", () => { + it("builds a complete FinTS 4.1 XML message", () => { + const result = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [ + { + type: "TestSeg", + version: 1, + segNo: 1, + body: "test", + }, + ], + blz: "12345678", + name: "testuser", + pin: "12345", + systemId: "0", + }); + + expect(result).toContain(''); + expect(result).toContain(``); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + it("includes PIN in the security envelope", () => { + const result = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [], + blz: "12345678", + name: "user", + pin: "mypin", + systemId: "0", + }); + + expect(result).toContain("mypin"); + }); + + it("includes TAN when provided", () => { + const result = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [], + blz: "12345678", + name: "user", + pin: "mypin", + systemId: "0", + tan: "123456", + }); + + expect(result).toContain("123456"); + }); + + it("includes multiple segments", () => { + const result = buildMessage({ + msgNo: 1, + dialogId: "0", + segments: [ + { type: "Seg1", version: 1, segNo: 1, body: "1" }, + { type: "Seg2", version: 2, segNo: 2, body: "2" }, + ], + blz: "12345678", + name: "user", + pin: "pin", + systemId: "0", + }); + + expect(result).toContain("Seg1"); + expect(result).toContain("Seg2"); + }); + }); +}); diff --git a/packages/fints/src/v4/__tests__/test-xml-parser.ts b/packages/fints/src/v4/__tests__/test-xml-parser.ts new file mode 100644 index 0000000..17cd285 --- /dev/null +++ b/packages/fints/src/v4/__tests__/test-xml-parser.ts @@ -0,0 +1,464 @@ +import { + parseResponse, + isFinTS4Response, + getXmlValue, + getXmlString, + getXmlNumber, + ensureArray, + isErrorCode, + findSegment, + findSegments, +} from "../xml-parser"; +import { FINTS_NAMESPACE } from "../constants"; + +describe("xml-parser", () => { + describe("isErrorCode", () => { + it("returns true for error codes starting with 9", () => { + expect(isErrorCode("9010")).toBe(true); + expect(isErrorCode("9999")).toBe(true); + }); + + it("returns false for success codes", () => { + expect(isErrorCode("0010")).toBe(false); + expect(isErrorCode("0100")).toBe(false); + }); + + it("returns false for warning codes starting with 3", () => { + expect(isErrorCode("3010")).toBe(false); + expect(isErrorCode("3920")).toBe(false); + }); + + it("returns false for empty code", () => { + expect(isErrorCode("")).toBe(false); + }); + }); + + describe("getXmlValue", () => { + it("retrieves a top-level value", () => { + const obj = { name: "test" }; + expect(getXmlValue(obj, "name")).toBe("test"); + }); + + it("retrieves a nested value", () => { + const obj = { a: { b: { c: "deep" } } }; + expect(getXmlValue(obj, "a.b.c")).toBe("deep"); + }); + + it("returns undefined for missing path", () => { + const obj = { a: { b: "test" } }; + expect(getXmlValue(obj, "a.c")).toBeUndefined(); + }); + + it("returns undefined for null input", () => { + expect(getXmlValue(null, "a")).toBeUndefined(); + }); + + it("returns undefined for undefined input", () => { + expect(getXmlValue(undefined, "a")).toBeUndefined(); + }); + + it("handles numeric values", () => { + const obj = { count: 42 }; + expect(getXmlValue(obj, "count")).toBe(42); + }); + }); + + describe("getXmlString", () => { + it("converts value to string", () => { + expect(getXmlString({ val: 42 }, "val")).toBe("42"); + }); + + it("returns undefined for missing value", () => { + expect(getXmlString({}, "missing")).toBeUndefined(); + }); + + it("returns string values as-is", () => { + expect(getXmlString({ name: "test" }, "name")).toBe("test"); + }); + }); + + describe("getXmlNumber", () => { + it("parses numeric string", () => { + expect(getXmlNumber({ val: "42" }, "val")).toBe(42); + }); + + it("returns number values as-is", () => { + expect(getXmlNumber({ val: 3.14 }, "val")).toBe(3.14); + }); + + it("returns undefined for missing value", () => { + expect(getXmlNumber({}, "missing")).toBeUndefined(); + }); + + it("returns undefined for NaN", () => { + expect(getXmlNumber({ val: "not a number" }, "val")).toBeUndefined(); + }); + }); + + describe("ensureArray", () => { + it("wraps a single value in an array", () => { + expect(ensureArray("test")).toEqual(["test"]); + }); + + it("returns an array as-is", () => { + expect(ensureArray([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it("returns empty array for null", () => { + expect(ensureArray(null)).toEqual([]); + }); + + it("returns empty array for undefined", () => { + expect(ensureArray(undefined)).toEqual([]); + }); + }); + + describe("isFinTS4Response", () => { + it("detects XML responses starting with declaration", () => { + expect(isFinTS4Response('')).toBe(true); + }); + + it("detects responses starting with FinTSMessage", () => { + expect(isFinTS4Response("...")).toBe(true); + }); + + it("rejects non-XML responses", () => { + expect(isFinTS4Response("HNHBK:1:3+000")).toBe(false); + }); + + it("handles whitespace before XML declaration", () => { + expect(isFinTS4Response(" { + expect(isFinTS4Response("")).toBe(false); + }); + }); + + describe("parseResponse", () => { + it("parses a basic response with dialog ID", () => { + const xml = ` + + + 1 + dialog123 + + + 1 + `; + + const result = parseResponse(xml); + expect(result.dialogId).toBe("dialog123"); + expect(result.msgNo).toBe(1); + expect(result.success).toBe(true); + }); + + it("parses success return values", () => { + const xml = ` + + + 1 + d1 + + + + 0010 + Nachricht entgegengenommen + + + `; + + const result = parseResponse(xml); + expect(result.success).toBe(true); + expect(result.returnValues.length).toBe(1); + expect(result.returnValues[0].code).toBe("0010"); + expect(result.returnValues[0].isError).toBe(false); + }); + + it("detects error return values", () => { + const xml = ` + + + 1 + d1 + + + + 9010 + Verarbeitung nicht möglich + + + `; + + const result = parseResponse(xml); + expect(result.success).toBe(false); + expect(result.returnValues[0].isError).toBe(true); + }); + + it("parses system ID from sync response", () => { + const xml = ` + + + 1 + d1 + + + + + SyncRes + 1 + 1 + + + sys-abc-123 + + + + `; + + const result = parseResponse(xml); + expect(result.systemId).toBe("sys-abc-123"); + }); + + it("parses BPD from response", () => { + const xml = ` + + + 1 + d1 + + + + + BPD + 1 + 2 + + + + Test Bank + 42 + + + + + `; + + const result = parseResponse(xml); + expect(result.bpd).toBeDefined(); + expect(result.bpd!.bankName).toBe("Test Bank"); + expect(result.bpd!.bpdVersion).toBe(42); + }); + + it("parses TAN methods from response", () => { + const xml = ` + + + 1 + d1 + + + + + TANMethods + 1 + 3 + + + + 912 + 2 + pushTAN + 6 + + + 913 + 1 + smsTAN + 8 + + + + + `; + + const result = parseResponse(xml); + expect(result.tanMethods).toBeDefined(); + expect(result.tanMethods!.length).toBe(2); + expect(result.tanMethods![0].securityFunction).toBe("912"); + expect(result.tanMethods![0].name).toBe("pushTAN"); + expect(result.tanMethods![1].securityFunction).toBe("913"); + expect(result.tanMethods![1].name).toBe("smsTAN"); + }); + + it("parses accounts from response", () => { + const xml = ` + + + 1 + d1 + + + + + AccountList + 1 + 4 + + + + DE89370400440532013000 + COBADEFFXXX + 0532013000 + 37040044 + Max Mustermann + + + + + `; + + const result = parseResponse(xml); + expect(result.accounts).toBeDefined(); + expect(result.accounts!.length).toBe(1); + expect(result.accounts![0].iban).toBe("DE89370400440532013000"); + expect(result.accounts![0].bic).toBe("COBADEFFXXX"); + expect(result.accounts![0].accountOwnerName).toBe("Max Mustermann"); + }); + + it("parses camt data from account statement response", () => { + const xml = ` + + + 1 + d1 + + + + + AccountStatement + 1 + 5 + + + camt xml content here + + + + `; + + const result = parseResponse(xml); + expect(result.camtData).toBe("camt xml content here"); + }); + + it("parses segment versions", () => { + const xml = ` + + 1d1 + + + + Balance + 3 + 1 + + + + + + AccountStatement + 2 + 2 + + + + + `; + + const result = parseResponse(xml); + expect(result.segmentVersions).toBeDefined(); + expect(result.segmentVersions!.get("Balance")).toBe(3); + expect(result.segmentVersions!.get("AccountStatement")).toBe(2); + }); + + it("parses touchdown from return values", () => { + const xml = ` + + 1d1 + + + 3040 + Es liegen weitere Informationen vor + touchdown-token-abc + + + `; + + const result = parseResponse(xml); + expect(result.touchdown).toBe("touchdown-token-abc"); + }); + + it("handles response without optional fields", () => { + const xml = ` + + 1d1 + + `; + + const result = parseResponse(xml); + expect(result.dialogId).toBe("d1"); + expect(result.success).toBe(true); + expect(result.systemId).toBeUndefined(); + expect(result.bpd).toBeUndefined(); + expect(result.tanMethods).toBeUndefined(); + expect(result.accounts).toBeUndefined(); + expect(result.camtData).toBeUndefined(); + }); + }); + + describe("findSegment", () => { + it("finds a segment by type", () => { + const msgBody = { + Segment: [ + { SegHead: { Type: "Seg1", Version: "1", SegNo: "1" }, SegBody: {} }, + { SegHead: { Type: "Seg2", Version: "2", SegNo: "2" }, SegBody: {} }, + ], + }; + + const result = findSegment(msgBody, "Seg2"); + expect(result).toBeDefined(); + expect((result!.SegHead as Record).Type).toBe("Seg2"); + }); + + it("returns undefined for missing segment", () => { + const msgBody = { + Segment: [{ SegHead: { Type: "Seg1", Version: "1", SegNo: "1" }, SegBody: {} }], + }; + + expect(findSegment(msgBody, "Missing")).toBeUndefined(); + }); + }); + + describe("findSegments", () => { + it("finds all segments of a type", () => { + const msgBody = { + Segment: [ + { SegHead: { Type: "Data", Version: "1", SegNo: "1" }, SegBody: { val: "a" } }, + { SegHead: { Type: "Other", Version: "1", SegNo: "2" }, SegBody: {} }, + { SegHead: { Type: "Data", Version: "2", SegNo: "3" }, SegBody: { val: "b" } }, + ], + }; + + const results = findSegments(msgBody, "Data"); + expect(results.length).toBe(2); + }); + + it("returns empty array when no segments match", () => { + const msgBody = { + Segment: [{ SegHead: { Type: "Seg1", Version: "1", SegNo: "1" }, SegBody: {} }], + }; + + expect(findSegments(msgBody, "Missing")).toEqual([]); + }); + }); +}); diff --git a/packages/fints/src/v4/camt-parser.ts b/packages/fints/src/v4/camt-parser.ts new file mode 100644 index 0000000..93975fc --- /dev/null +++ b/packages/fints/src/v4/camt-parser.ts @@ -0,0 +1,216 @@ +/** + * Parser for ISO 20022 camt.053 (Bank-to-Customer Statement) XML format. + * + * This parser extracts transaction data from camt.053 XML responses + * as returned by FinTS 4.1 servers for account statement requests. + */ +import { XMLParser } from "fast-xml-parser"; +import { CamtStatement, CamtEntry } from "./types"; +import { ensureArray, getXmlValue, getXmlString } from "./xml-parser"; + +/** + * Parser options for camt XML. + */ +const camtParserOptions = { + ignoreAttributes: false, + attributeNamePrefix: "@_", + parseAttributeValue: false, + parseTagValue: false, + trimValues: true, + isArray: (name: string) => { + return ["Stmt", "Ntry", "TxDtls", "Ustrd", "Bal"].includes(name); + }, +}; + +/** + * Parse a date string (YYYY-MM-DD) into a Date object. + */ +function parseDate(dateStr: string | undefined): Date | undefined { + if (!dateStr) return undefined; + const date = new Date(dateStr); + return isNaN(date.getTime()) ? undefined : date; +} + +/** + * Parse the amount from a camt entry. + * The amount node has text content and a Ccy attribute. + */ +function parseAmount(amtNode: unknown): { amount: number; currency: string } { + if (amtNode == null) { + return { amount: 0, currency: "EUR" }; + } + + if (typeof amtNode === "object") { + const obj = amtNode as Record; + // fast-xml-parser represents element text as "#text" when attributes exist. + // "Amt" and "InstdAmt" are alternative amount field names in different + // ISO 20022 camt format versions (camt.053.001.02 vs camt.053.001.08). + const text = obj["#text"] ?? obj["Amt"] ?? obj["InstdAmt"] ?? ""; + const ccy = (obj["@_Ccy"] as string) || "EUR"; + return { + amount: parseFloat(String(text)) || 0, + currency: ccy, + }; + } + + return { + amount: parseFloat(String(amtNode)) || 0, + currency: "EUR", + }; +} + +/** + * Parse a single camt entry (Ntry element). + */ +function parseCamtEntry(entry: Record): CamtEntry { + const { amount, currency } = parseAmount(getXmlValue(entry, "Amt")); + + const creditDebitIndicator = getXmlString(entry, "CdtDbtInd") === "DBIT" ? "DBIT" : "CRDT"; + + // Booking date + const bookingDate = parseDate(getXmlString(entry, "BookgDt.Dt") || getXmlString(entry, "BookgDt.DtTm")); + + // Value date + const valueDate = parseDate(getXmlString(entry, "ValDt.Dt") || getXmlString(entry, "ValDt.DtTm")); + + // Transaction details - usually nested under NtryDtls/TxDtls + const txDtls = ensureArray(getXmlValue(entry, "NtryDtls.TxDtls") as Record[]); + const firstTx = txDtls[0]; + + let remittanceInformation: string | undefined; + let counterpartyName: string | undefined; + let counterpartyIban: string | undefined; + let counterpartyBic: string | undefined; + let endToEndReference: string | undefined; + let mandateReference: string | undefined; + + if (firstTx) { + // Remittance information + const rmtInfUstrd = getXmlValue(firstTx, "RmtInf.Ustrd"); + if (rmtInfUstrd != null) { + const ustrdArr = ensureArray(rmtInfUstrd as string[]); + remittanceInformation = ustrdArr.map(String).join(" "); + } + + // Counterparty (Related Parties) + // For credit entries, counterparty is the debtor; for debit entries, it's the creditor + if (creditDebitIndicator === "CRDT") { + counterpartyName = getXmlString(firstTx, "RltdPties.Dbtr.Nm"); + counterpartyIban = getXmlString(firstTx, "RltdPties.DbtrAcct.Id.IBAN"); + counterpartyBic = + getXmlString(firstTx, "RltdAgts.DbtrAgt.FinInstnId.BIC") || + getXmlString(firstTx, "RltdAgts.DbtrAgt.FinInstnId.BICFI"); + } else { + counterpartyName = getXmlString(firstTx, "RltdPties.Cdtr.Nm"); + counterpartyIban = getXmlString(firstTx, "RltdPties.CdtrAcct.Id.IBAN"); + counterpartyBic = + getXmlString(firstTx, "RltdAgts.CdtrAgt.FinInstnId.BIC") || + getXmlString(firstTx, "RltdAgts.CdtrAgt.FinInstnId.BICFI"); + } + + // References + endToEndReference = getXmlString(firstTx, "Refs.EndToEndId"); + mandateReference = getXmlString(firstTx, "Refs.MndtId"); + } + + // Bank transaction code + const bankTransactionCode = getXmlString(entry, "BkTxCd.Domn.Cd"); + + return { + entryReference: getXmlString(entry, "NtryRef"), + amount: creditDebitIndicator === "DBIT" ? -amount : amount, + currency, + creditDebitIndicator, + bookingDate, + valueDate, + remittanceInformation, + counterpartyName, + counterpartyIban, + counterpartyBic, + endToEndReference, + mandateReference, + bankTransactionCode, + }; +} + +/** + * Parse a single camt statement (Stmt element). + */ +function parseCamtStatement(stmt: Record): CamtStatement { + const entries = ensureArray(getXmlValue(stmt, "Ntry") as Record[]); + + // Opening balance + const balances = ensureArray(getXmlValue(stmt, "Bal") as Record[]); + let openingBalance: number | undefined; + let closingBalance: number | undefined; + let currency: string | undefined; + + for (const bal of balances) { + const balType = getXmlString(bal, "Tp.CdOrPrtry.Cd"); + const { amount, currency: balCcy } = parseAmount(getXmlValue(bal, "Amt")); + const cdtDbt = getXmlString(bal, "CdtDbtInd"); + const signedAmount = cdtDbt === "DBIT" ? -amount : amount; + currency = balCcy; + + if (balType === "OPBD" || balType === "PRCD") { + openingBalance = signedAmount; + } else if (balType === "CLBD" || balType === "CLAV") { + closingBalance = signedAmount; + } + } + + return { + id: getXmlString(stmt, "Id") || "", + iban: getXmlString(stmt, "Acct.Id.IBAN"), + creationDate: parseDate(getXmlString(stmt, "CreDtTm")), + openingBalance, + closingBalance, + currency, + entries: entries.map(parseCamtEntry), + }; +} + +/** + * Parse a complete camt.053 XML document. + * + * @param xmlString The raw camt.053 XML string. + * @returns An array of parsed statements. + */ +export function parseCamt053(xmlString: string): CamtStatement[] { + if (!xmlString || xmlString.trim().length === 0) { + return []; + } + + const parser = new XMLParser(camtParserOptions); + const parsed = parser.parse(xmlString); + + // Navigate to the statement level: + // Document > BkToCstmrStmt > Stmt + const doc = parsed.Document || parsed; + const bkToCstmrStmt = doc.BkToCstmrStmt || doc; + const statements = ensureArray(bkToCstmrStmt.Stmt as Record[]); + + return statements.map(parseCamtStatement); +} + +/** + * Parse camt.052 (Account Report) - similar structure to camt.053. + * + * @param xmlString The raw camt.052 XML string. + * @returns An array of parsed statements. + */ +export function parseCamt052(xmlString: string): CamtStatement[] { + if (!xmlString || xmlString.trim().length === 0) { + return []; + } + + const parser = new XMLParser(camtParserOptions); + const parsed = parser.parse(xmlString); + + // Document > BkToCstmrAcctRpt > Rpt + const doc = parsed.Document || parsed; + const report = doc.BkToCstmrAcctRpt || doc; + const reports = ensureArray((report.Rpt || report.Stmt) as Record[]); + + return reports.map(parseCamtStatement); +} diff --git a/packages/fints/src/v4/client.ts b/packages/fints/src/v4/client.ts new file mode 100644 index 0000000..e54a265 --- /dev/null +++ b/packages/fints/src/v4/client.ts @@ -0,0 +1,271 @@ +/** + * FinTS 4.1 Client implementation. + * + * Provides read-only operations using the FinTS 4.1 XML-based protocol: + * - Account listing + * - Balance queries + * - Account statements (using camt.053 format) + * + * This client follows the same patterns as the existing PinTanClient but uses + * XML messages conforming to the FinTS 4.1 specification. + */ +import { FinTS4ClientConfig, FinTS4Connection, CamtStatement } from "./types"; +import { FinTS4Dialog } from "./dialog"; +import { FinTS4HttpConnection, createTlsAgent } from "./connection"; +import { SEPAAccount, Balance, Statement, BankCapabilities } from "../types"; +import { PRODUCT_NAME } from "../constants"; +import { parseCamt053 } from "./camt-parser"; +import { + buildAccountListSegment, + buildBalanceSegment, + buildAccountStatementSegment, + buildTanSegment, +} from "./segments"; +import { XmlSegment } from "./xml-builder"; + +/** + * Client for FinTS 4.1 XML-based protocol. + * + * Supports read-only operations: + * - Listing SEPA accounts + * - Querying account balances + * - Fetching account statements (camt.053 format) + * - Retrieving bank capabilities + */ +export class FinTS4Client { + private config: FinTS4ClientConfig; + private connection: FinTS4Connection; + + constructor(config: FinTS4ClientConfig) { + this.config = config; + // If tlsOptions is provided directly, convert it to an agent and merge into fetchOptions. + // This spares callers from having to manually call createTlsAgent(). + let fetchOptions = config.fetchOptions ?? {}; + if (config.tlsOptions && !fetchOptions["agent"]) { + try { + fetchOptions = { ...fetchOptions, agent: createTlsAgent(config.tlsOptions) }; + } catch { + // Not in a Node.js environment — tlsOptions silently ignored. + } + } + this.connection = new FinTS4HttpConnection({ + url: config.url, + debug: config.debug, + timeout: config.timeout, + maxRetries: config.maxRetries, + retryDelay: config.retryDelay, + fetchOptions, + }); + } + + /** + * Create a new FinTS 4.1 dialog. + */ + public createDialog(): FinTS4Dialog { + const dialog = new FinTS4Dialog( + { + blz: this.config.blz, + name: this.config.name, + pin: this.config.pin, + systemId: "0", + productId: this.config.productId || PRODUCT_NAME, + tanCallback: this.config.tanCallback, + }, + this.connection, + ); + if (this.config.preferredHbciVersion) { + dialog.hbciVersion = this.config.preferredHbciVersion; + } + return dialog; + } + + /** + * Retrieve the capabilities of the bank. + */ + public async capabilities(): Promise { + const dialog = this.createDialog(); + await dialog.sync(); + return dialog.capabilities; + } + + /** + * Fetch a list of all SEPA accounts accessible by the user. + */ + public async accounts(): Promise { + const dialog = this.createDialog(); + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send([buildAccountListSegment({ segNo: 3 })]); + await dialog.end(); + + return response.accounts || []; + } + + /** + * Fetch the balance for a SEPA account. + */ + public async balance(account: SEPAAccount): Promise { + const dialog = this.createDialog(); + await dialog.sync(); + await dialog.init(); + + const response = await dialog.send( + [ + buildBalanceSegment({ + segNo: 3, + version: dialog.balanceVersion, + account, + }), + ], + { account }, + ); + await dialog.end(); + + if (response.balance) { + return response.balance; + } + + // Fallback: return a zeroed balance so callers always receive a Balance object + return { + account, + availableBalance: 0, + bookedBalance: 0, + currency: "EUR", + creditLimit: 0, + pendingBalance: 0, + productName: "", + }; + } + + /** + * Fetch account statements in camt.053 format. + * + * Returns parsed camt statements with transaction entries. + */ + public async camtStatements(account: SEPAAccount, startDate?: Date, endDate?: Date): Promise { + const dialog = this.createDialog(); + await dialog.sync(); + await dialog.init(); + + const allStatements: CamtStatement[] = []; + let touchdown: string | undefined; + + do { + const segments: XmlSegment[] = [ + buildAccountStatementSegment({ + segNo: 3, + version: dialog.statementVersion, + account, + startDate, + endDate, + touchdown, + }), + ]; + + // Add TAN segment if required + if (dialog.tanVersion >= 1 && dialog.tanMethods.length > 0 && !touchdown) { + segments.push( + buildTanSegment({ + segNo: 4, + version: dialog.tanVersion, + process: "4", + segmentReference: "AccountStatement", + medium: dialog.tanMethods[0]?.name, + }), + ); + } + + const response = await dialog.send(segments); + + if (response.camtData) { + const parsed = parseCamt053(response.camtData); + allStatements.push(...parsed); + } + + touchdown = response.touchdown; + } while (touchdown); + + await dialog.end(); + return allStatements; + } + + /** + * Fetch account statements and convert them to the common Statement format + * used by the FinTS 3.0 client for backward compatibility. + */ + public async statements(account: SEPAAccount, startDate?: Date, endDate?: Date): Promise { + const camtStatements = await this.camtStatements(account, startDate, endDate); + return this.convertCamtToStatements(camtStatements); + } + + /** + * Convert a balance value to the MT940 BalanceInfo format. + */ + private toBalanceInfo( + amount: number, + date: Date | undefined, + currency: string, + ): { isCredit: boolean; date: string; currency: string; value: number } { + return { + isCredit: amount >= 0, + date: date ? date.toISOString().slice(0, 10) : "", + currency, + value: Math.abs(amount), + }; + } + + /** + * Convert camt statements to the common Statement format for compatibility + * with the FinTS 3.0 MT940-based statements. + */ + private convertCamtToStatements(camtStatements: CamtStatement[]): Statement[] { + return camtStatements.map((stmt) => { + const currency = stmt.currency || "EUR"; + return { + referenceNumber: stmt.id, + accountId: stmt.iban || "", + number: stmt.id, + openingBalance: + stmt.openingBalance != null + ? this.toBalanceInfo(stmt.openingBalance, stmt.creationDate, currency) + : undefined, + closingBalance: + stmt.closingBalance != null + ? this.toBalanceInfo(stmt.closingBalance, stmt.creationDate, currency) + : undefined, + transactions: stmt.entries.map((entry) => ({ + id: entry.entryReference || "", + code: entry.bankTransactionCode || "", + fundsCode: "", + isCredit: entry.creditDebitIndicator === "CRDT", + isExpense: entry.creditDebitIndicator === "DBIT", + currency: entry.currency, + description: entry.remittanceInformation || "", + amount: Math.abs(entry.amount), + valueDate: entry.valueDate ? entry.valueDate.toISOString().slice(0, 10) : "", + entryDate: entry.bookingDate ? entry.bookingDate.toISOString().slice(0, 10) : "", + customerReference: entry.endToEndReference || "", + bankReference: "", + descriptionStructured: entry.counterpartyName + ? { + reference: { + raw: entry.remittanceInformation || "", + endToEndRef: entry.endToEndReference, + mandateRef: entry.mandateReference, + iban: entry.counterpartyIban, + bic: entry.counterpartyBic, + text: entry.remittanceInformation, + }, + name: entry.counterpartyName || "", + iban: entry.counterpartyIban || "", + bic: entry.counterpartyBic || "", + text: entry.remittanceInformation || "", + primaNota: "", + } + : undefined, + })), + }; + }); + } +} diff --git a/packages/fints/src/v4/connection.ts b/packages/fints/src/v4/connection.ts new file mode 100644 index 0000000..fed78c8 --- /dev/null +++ b/packages/fints/src/v4/connection.ts @@ -0,0 +1,165 @@ +/** + * FinTS 4.1 HTTP connection. + * + * Handles sending XML requests to FinTS 4.1 servers over HTTP. + * Unlike FinTS 3.0 which uses Base64-encoded proprietary format, + * FinTS 4.1 uses XML over HTTP POST. + */ +import "isomorphic-fetch"; +import { FinTS4Connection, FinTS4TlsOptions } from "./types"; +import { verbose } from "../logger"; + +/** + * Configuration for a FinTS 4.1 HTTP connection. + */ +export interface FinTS4ConnectionConfig { + /** The URL of the FinTS server. */ + url: string; + /** Whether to log requests/responses. */ + debug?: boolean; + /** Request timeout in milliseconds. */ + timeout?: number; + /** Maximum retry attempts. */ + maxRetries?: number; + /** Base delay for exponential backoff in ms. */ + retryDelay?: number; + /** + * Additional options forwarded to `fetch()` on every request. + * In Node.js, use this to pass a custom `https.Agent`: + * ```typescript + * import https from "https"; + * const agent = new https.Agent({ rejectUnauthorized: false }); + * new FinTS4HttpConnection({ url, fetchOptions: { agent } }); + * ``` + */ + fetchOptions?: Record; +} + +/** + * Create an `https.Agent` for Node.js with the given TLS options. + * + * Pass the returned agent via `fetchOptions.agent` when constructing + * `FinTS4HttpConnection` or `FinTS4ClientConfig`: + * + * ```typescript + * const agent = createTlsAgent({ rejectUnauthorized: false }); // dev only! + * const client = new FinTS4Client({ ..., fetchOptions: { agent } }); + * ``` + * + * @throws {Error} When called outside a Node.js environment (e.g. in a browser). + */ +export function createTlsAgent(options: FinTS4TlsOptions): unknown { + if (typeof require === "undefined") { + throw new Error( + "createTlsAgent() requires a Node.js environment. " + + "Browser environments do not support custom HTTPS agents.", + ); + } + // eslint-disable-next-line @typescript-eslint/no-require-imports + const https = require("https") as typeof import("https"); + return new https.Agent({ + rejectUnauthorized: options.rejectUnauthorized ?? true, + ...(options.ca ? { ca: options.ca } : {}), + }); +} + +/** + * Redact sensitive credential fields (``, ``) in an XML string + * before writing it to a debug log. + */ +function redactXmlCredentials(xml: string): string { + return xml.replace(/()[^<]*/gi, "$1***").replace(/()[^<]*/gi, "$1***"); +} + +/** + * HTTP connection for FinTS 4.1 XML-based communication. + */ +export class FinTS4HttpConnection implements FinTS4Connection { + private url: string; + private debug: boolean; + private timeout: number; + private maxRetries: number; + private retryDelay: number; + private fetchOptions: Record; + + constructor(config: FinTS4ConnectionConfig) { + this.url = config.url; + this.debug = config.debug ?? false; + this.timeout = config.timeout ?? 30000; + this.maxRetries = config.maxRetries ?? 3; + this.retryDelay = config.retryDelay ?? 1000; + this.fetchOptions = config.fetchOptions ?? {}; + } + + /** + * Send an XML request string and return the XML response string. + */ + public async send(xmlRequest: string): Promise { + verbose(`FinTS 4.1: Sending request to ${this.url}`); + if (this.debug) { + verbose(`FinTS 4.1 Request XML:\n${redactXmlCredentials(xmlRequest)}`); + } + + let lastError: Error | null = null; + let attempt = 0; + + while (attempt <= this.maxRetries) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const httpResponse = await fetch(this.url, { + method: "POST", + headers: { + "Content-Type": "application/xml; charset=UTF-8", + "Accept": "application/xml", + }, + body: xmlRequest, + signal: controller.signal, + ...this.fetchOptions, + }); + + if (!httpResponse.ok) { + throw new Error(`FinTS 4.1: Received bad status code ${httpResponse.status} from endpoint.`); + } + + const responseText = await httpResponse.text(); + verbose(`FinTS 4.1: Received response`); + if (this.debug) { + verbose(`FinTS 4.1 Response XML:\n${redactXmlCredentials(responseText)}`); + } + return responseText; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + lastError = error as Error; + attempt++; + + const isTimeout = (error as Error).name === "AbortError"; + + if (attempt <= this.maxRetries) { + const delay = this.retryDelay * Math.pow(2, attempt - 1); + verbose( + `FinTS 4.1: Request failed (attempt ${attempt}/${this.maxRetries + 1}), ` + + `retrying in ${delay}ms: ${(error as Error).message}`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + if (isTimeout) { + throw new Error( + `FinTS 4.1: Request timed out after ${this.maxRetries + 1} attempts ` + + `(timeout: ${this.timeout}ms)`, + ); + } + throw new Error( + `FinTS 4.1: Request failed after ${this.maxRetries + 1} attempts: ` + `${lastError.message}`, + ); + } + } + } + + throw lastError || new Error("Unknown error during FinTS 4.1 request"); + } +} diff --git a/packages/fints/src/v4/constants.ts b/packages/fints/src/v4/constants.ts new file mode 100644 index 0000000..04d6134 --- /dev/null +++ b/packages/fints/src/v4/constants.ts @@ -0,0 +1,56 @@ +/** + * Constants specific to the FinTS 4.1 protocol. + */ + +/** + * The FinTS protocol version identifier for version 4.1. + */ +export const FINTS_VERSION = "4.1"; + +/** + * XML namespace for FinTS 4.1 messages. + */ +export const FINTS_NAMESPACE = "urn:org:fints:4.1"; + +/** + * XML namespace for camt.053 (Bank-to-Customer Statement). + */ +export const CAMT_053_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"; + +/** + * XML namespace for camt.052 (Bank-to-Customer Account Report). + */ +export const CAMT_052_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02"; + +/** + * Country code for Germany. + */ +export const COUNTRY_CODE = "280"; + +/** + * Default language code (German). + */ +export const LANG_DE = "de"; + +/** + * Security method: PIN/TAN. + */ +export const SECURITY_METHOD_PIN_TAN = "PIN_TAN"; + +/** + * Default segment version for v4.1 segments. + */ +export const DEFAULT_SEGMENT_VERSION = 1; + +/** + * XML declaration for messages. + */ +export const XML_DECLARATION = ''; + +/** + * Supported camt formats for account statements in v4.1. + */ +export const SUPPORTED_CAMT_FORMATS = [ + "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02", + "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02", +] as const; diff --git a/packages/fints/src/v4/dialog.ts b/packages/fints/src/v4/dialog.ts new file mode 100644 index 0000000..7dcf441 --- /dev/null +++ b/packages/fints/src/v4/dialog.ts @@ -0,0 +1,453 @@ +/** + * FinTS 4.1 Dialog implementation. + * + * Manages the dialog lifecycle for FinTS 4.1 XML-based communication. + */ +import { FinTS4DialogConfig, FinTS4Connection, FinTS4Response, BankParameterData, TanCallback } from "./types"; +import { buildMessage, XmlSegment } from "./xml-builder"; +import { parseResponse } from "./xml-parser"; +import { TanMethod } from "../tan-method"; +import { BankCapabilities, SEPAAccount } from "../types"; +import { PRODUCT_NAME } from "../constants"; +import { FINTS_VERSION } from "./constants"; +import { + buildDialogInitSegment, + buildDialogEndSegment, + buildSyncSegment, + buildTanSegment, + buildTanSubmitSegment, +} from "./segments"; +import { verbose } from "../logger"; + +/** FinTS return code: version not supported. */ +const RETURN_CODE_VERSION_NOT_SUPPORTED = "9010"; + +/** HBCI versions to try when version negotiation fails, in descending preference order. */ +const FALLBACK_HBCI_VERSIONS = ["4.1", "4.0", "3.0"]; + +/** + * Error thrown when the server requires a TAN but no `tanCallback` was configured. + * + * Unlike the FinTS 3.0 `TanRequiredError`, this version is simpler: it surfaces + * only the data needed for the v4 two-step TAN flow. + */ +export class FinTS4TanRequiredError extends Error { + /** The TAN challenge details from the server. */ + public readonly transactionReference: string; + /** The challenge text to display to the user. */ + public readonly challengeText?: string; + + constructor(transactionReference: string, challengeText?: string) { + super( + `FinTS 4.1: Server requires a TAN (ref: ${transactionReference}). ` + + `Configure a 'tanCallback' to handle interactive TAN challenges.`, + ); + this.transactionReference = transactionReference; + this.challengeText = challengeText; + if (Error.captureStackTrace) { + Error.captureStackTrace(this, FinTS4TanRequiredError); + } + } +} + +/** + * A dialog representing a session with a FinTS 4.1 server. + */ +export class FinTS4Dialog { + /** The bank's identification number. */ + public blz: string; + /** The username or customer ID. */ + public name: string; + /** The PIN. */ + public pin: string; + /** System ID assigned by the server. */ + public systemId: string; + /** Product registration ID. */ + public productId: string; + /** Current message number within the dialog. */ + public msgNo = 1; + /** Dialog ID assigned by the server. */ + public dialogId = "0"; + /** TAN methods supported by the server. */ + public tanMethods: TanMethod[] = []; + /** Bank Parameter Data from synchronization. */ + public bpd?: BankParameterData; + /** Supported segment versions (segment type -> max version). */ + public segmentVersions = new Map(); + /** Whether the bank supports balance queries. */ + public supportsBalance = false; + /** Whether the bank supports account statements. */ + public supportsStatements = false; + /** Whether the bank supports account listing. */ + public supportsAccounts = true; + /** Supported camt formats. */ + public supportedCamtFormats: string[] = []; + /** Supported pain formats. */ + public painFormats: string[] = []; + /** The version for balance segment. */ + public balanceVersion = 1; + /** The version for account statement segment. */ + public statementVersion = 1; + /** The version for TAN segment. */ + public tanVersion = 1; + /** Security function to use. */ + public securityFunction = "999"; + /** Minimum signatures required for statements. */ + public statementsMinSignatures = 0; + /** Minimum signatures required for balance. */ + public balanceMinSignatures = 0; + /** + * The HBCI protocol version string used in outgoing messages. + * Updated during version negotiation when the bank rejects the preferred version. + */ + public hbciVersion: string = FINTS_VERSION; + /** Optional callback for interactive TAN challenges. */ + public tanCallback?: TanCallback; + + /** The connection to the server. */ + private connection: FinTS4Connection; + + constructor(config: FinTS4DialogConfig, connection: FinTS4Connection) { + this.blz = config.blz; + this.name = config.name; + this.pin = config.pin; + this.systemId = config.systemId || "0"; + this.productId = config.productId || PRODUCT_NAME; + this.connection = connection; + this.tanCallback = config.tanCallback; + } + + /** + * Send an XML request to the server and parse the response. + * + * When the server responds with a TAN challenge (return code `0030`) and a + * `tanCallback` is configured, the challenge is automatically resolved: + * the callback is invoked, and the TAN is submitted in a follow-up message. + * If no `tanCallback` is configured, a `FinTS4TanRequiredError` is thrown. + */ + public async send( + segments: XmlSegment[], + options?: { pin?: string; tan?: string; account?: SEPAAccount }, + ): Promise { + const xmlRequest = buildMessage({ + msgNo: this.msgNo, + dialogId: this.dialogId, + segments, + blz: this.blz, + name: this.name, + pin: options?.pin ?? this.pin, + systemId: this.systemId, + productId: this.productId, + tan: options?.tan, + securityFunction: this.securityFunction, + hbciVersion: this.hbciVersion, + }); + + verbose(`FinTS 4.1 sending message #${this.msgNo} to dialog ${this.dialogId}`); + const responseXml = await this.connection.send(xmlRequest); + const response = parseResponse(responseXml, options?.account); + + if (response.dialogId && response.dialogId !== "0") { + this.dialogId = response.dialogId; + } + + if (!response.success) { + const errors = response.returnValues + .filter((rv) => rv.isError) + .map((rv) => `${rv.code}: ${rv.message}`) + .join("; "); + throw new Error(`FinTS 4.1 request failed: ${errors}`); + } + + this.msgNo++; + + // Handle interactive TAN challenge + if (response.tanRequired && response.tanChallenge) { + return this.handleTanChallenge(response); + } + + return response; + } + + /** + * Handle a TAN challenge from the server. + * + * Invokes `tanCallback` if configured and submits the TAN, then returns + * the server's final response. Throws `FinTS4TanRequiredError` if no callback + * is configured. + */ + private async handleTanChallenge(challengeResponse: FinTS4Response): Promise { + const challenge = challengeResponse.tanChallenge!; + + if (!this.tanCallback) { + throw new FinTS4TanRequiredError(challenge.transactionReference, challenge.challengeText); + } + + verbose( + `FinTS 4.1: TAN challenge received (ref: ${challenge.transactionReference}). ` + `Invoking tanCallback.`, + ); + + const tan = await this.tanCallback(challenge); + + // Submit the TAN in a new message within the same dialog + const tanSegments: XmlSegment[] = [ + buildTanSubmitSegment({ + segNo: 1, + version: this.tanVersion, + transactionReference: challenge.transactionReference, + }), + ]; + + const xmlRequest = buildMessage({ + msgNo: this.msgNo, + dialogId: this.dialogId, + segments: tanSegments, + blz: this.blz, + name: this.name, + pin: this.pin, + systemId: this.systemId, + productId: this.productId, + tan, + securityFunction: this.securityFunction, + hbciVersion: this.hbciVersion, + }); + + verbose(`FinTS 4.1 submitting TAN for reference ${challenge.transactionReference}`); + const responseXml = await this.connection.send(xmlRequest); + const response = parseResponse(responseXml); + + if (response.dialogId && response.dialogId !== "0") { + this.dialogId = response.dialogId; + } + + if (!response.success) { + const errors = response.returnValues + .filter((rv) => rv.isError) + .map((rv) => `${rv.code}: ${rv.message}`) + .join("; "); + throw new Error(`FinTS 4.1 TAN submission failed: ${errors}`); + } + + this.msgNo++; + return response; + } + + /** + * Perform synchronization to obtain system ID, BPD, UPD, and TAN methods. + * + * If the server rejects the HBCI version with error `9010`, the dialog + * automatically retries with the next version in the fallback list. + */ + public async sync(): Promise { + const versionsToTry = this.buildVersionCandidates(); + + for (let i = 0; i < versionsToTry.length; i++) { + const version = versionsToTry[i]; + this.hbciVersion = version; + + try { + return await this.doSync(); + } catch (err) { + const msg = (err as Error).message || ""; + const isVersionError = msg.includes(RETURN_CODE_VERSION_NOT_SUPPORTED); + + if (isVersionError && i < versionsToTry.length - 1) { + verbose(`FinTS 4.1: Server rejected version ${version}, retrying with next version.`); + // Reset dialog state before retry + this.dialogId = "0"; + this.msgNo = 1; + continue; + } + throw err; + } + } + + // Should never reach here + throw new Error("FinTS 4.1: Exhausted all HBCI version candidates without success."); + } + + /** + * Build the list of HBCI versions to try, starting from the current `hbciVersion`. + */ + private buildVersionCandidates(): string[] { + const startIdx = FALLBACK_HBCI_VERSIONS.indexOf(this.hbciVersion); + if (startIdx === -1) { + return [this.hbciVersion, ...FALLBACK_HBCI_VERSIONS]; + } + return FALLBACK_HBCI_VERSIONS.slice(startIdx); + } + + /** + * Internal sync implementation (one attempt, one version). + */ + private async doSync(): Promise { + const segments: XmlSegment[] = [ + buildDialogInitSegment({ + segNo: 1, + blz: this.blz, + name: this.name, + systemId: "0", + productId: this.productId, + hbciVersion: this.hbciVersion, + }), + buildSyncSegment({ segNo: 2, mode: 0 }), + ]; + + const response = await this.send(segments, { pin: this.pin }); + + // Update system ID + if (response.systemId) { + this.systemId = response.systemId; + } + + // Update BPD + if (response.bpd) { + this.bpd = response.bpd; + } + + // Update TAN methods + if (response.tanMethods && response.tanMethods.length > 0) { + this.tanMethods = response.tanMethods; + // Update security function + // Use the first TAN method's security function if 999 is not available + const hasDefaultMethod = this.tanMethods.some((m) => m.securityFunction === "999"); + if (!hasDefaultMethod && this.tanMethods.length > 0) { + this.securityFunction = this.tanMethods[0].securityFunction!; + } + } + + // Update segment versions + if (response.segmentVersions) { + this.segmentVersions = response.segmentVersions; + } + + // Determine capabilities and min-signature requirements + this.updateCapabilities(); + + // Update supported formats + if (response.supportedCamtFormats) { + this.supportedCamtFormats = response.supportedCamtFormats; + } + if (response.painFormats) { + this.painFormats = response.painFormats; + } + + // Negotiate HBCI version from bank's supported-versions list + if (response.supportedHbciVersions && response.supportedHbciVersions.length > 0) { + const negotiated = this.negotiateVersion(response.supportedHbciVersions); + if (negotiated) { + this.hbciVersion = negotiated; + if (this.bpd) { + this.bpd.negotiatedVersion = negotiated; + } + verbose(`FinTS 4.1: Negotiated HBCI version ${negotiated}`); + } + } + + // End the sync dialog + await this.end(); + + return response; + } + + /** + * Select the highest mutually supported HBCI version. + * Returns undefined if no match is found. + */ + private negotiateVersion(serverVersions: string[]): string | undefined { + for (const candidate of FALLBACK_HBCI_VERSIONS) { + if (serverVersions.includes(candidate)) { + return candidate; + } + } + return undefined; + } + + /** + * Initialize a new dialog for performing business transactions. + */ + public async init(): Promise { + const segments: XmlSegment[] = [ + buildDialogInitSegment({ + segNo: 1, + blz: this.blz, + name: this.name, + systemId: this.systemId, + productId: this.productId, + hbciVersion: this.hbciVersion, + }), + ]; + + // Add TAN segment if supported + if (this.tanVersion >= 1 && this.tanMethods.length > 0) { + segments.push( + buildTanSegment({ + segNo: 2, + version: this.tanVersion, + process: "4", + }), + ); + } + + const response = await this.send(segments, { pin: this.pin }); + return response; + } + + /** + * End the current dialog. + */ + public async end(): Promise { + try { + await this.send([buildDialogEndSegment({ segNo: 1, dialogId: this.dialogId })], { pin: this.pin }); + } finally { + this.dialogId = "0"; + this.msgNo = 1; + } + } + + /** + * Update capability flags based on segment versions received during sync. + * Also updates min-signature requirements from BPD params. + */ + private updateCapabilities(): void { + const balVer = this.segmentVersions.get("Balance") || this.segmentVersions.get("HISALS") || 0; + this.supportsBalance = balVer > 0; + if (balVer > 0) this.balanceVersion = balVer; + + const stmtVer = + this.segmentVersions.get("AccountStatement") || + this.segmentVersions.get("HIKAZS") || + this.segmentVersions.get("HICAZS") || + 0; + this.supportsStatements = stmtVer > 0; + if (stmtVer > 0) this.statementVersion = stmtVer; + + const tanVer = this.segmentVersions.get("TAN") || this.segmentVersions.get("HITANS") || 0; + if (tanVer > 0) this.tanVersion = tanVer; + + // Update min-signature requirements from BPD + if (this.bpd?.minSignaturesBalance != null) { + this.balanceMinSignatures = this.bpd.minSignaturesBalance; + } + if (this.bpd?.minSignaturesStatement != null) { + this.statementsMinSignatures = this.bpd.minSignaturesStatement; + } + } + + /** + * Get the bank capabilities based on the synchronization data. + */ + public get capabilities(): BankCapabilities { + return { + supportsAccounts: true, + supportsBalance: this.supportsBalance, + supportsTransactions: this.supportsStatements, + supportsHoldings: false, // Not yet implemented in v4.1 + supportsStandingOrders: false, // Not yet implemented in v4.1 + supportsCreditTransfer: false, // Read-only for now + supportsDirectDebit: false, // Read-only for now + requiresTanForTransactions: this.statementsMinSignatures > 0, + requiresTanForBalance: this.balanceMinSignatures > 0, + }; + } +} diff --git a/packages/fints/src/v4/index.ts b/packages/fints/src/v4/index.ts new file mode 100644 index 0000000..de1a4ba --- /dev/null +++ b/packages/fints/src/v4/index.ts @@ -0,0 +1,64 @@ +/** + * FinTS 4.1 module exports. + */ +export { FinTS4Client } from "./client"; +export { FinTS4Dialog, FinTS4TanRequiredError } from "./dialog"; +export { FinTS4HttpConnection, createTlsAgent } from "./connection"; +export type { FinTS4ConnectionConfig } from "./connection"; +export type { + FinTS4ClientConfig, + FinTS4DialogConfig, + FinTS4Connection, + FinTS4Response, + FinTS4ReturnValue, + BankParameterData, + UserParameterData, + UserAccount, + CamtStatement, + CamtEntry, + MsgHead, + MsgTail, + TanChallenge, + TanCallback, + FinTS4TlsOptions, +} from "./types"; +export { + FINTS_VERSION, + FINTS_NAMESPACE, + CAMT_053_NAMESPACE, + CAMT_052_NAMESPACE, + SUPPORTED_CAMT_FORMATS, +} from "./constants"; +export { + buildMessage, + buildMsgHead, + buildMsgTail, + buildSecurityEnvelope, + buildSegment, + escapeXml, + xmlElement, + xmlEmptyElement, +} from "./xml-builder"; +export type { XmlSegment, XmlMessageOptions } from "./xml-builder"; +export { + parseResponse, + isFinTS4Response, + getXmlValue, + getXmlString, + getXmlNumber, + ensureArray, + isErrorCode as isFinTS4ErrorCode, + findSegment, + findSegments, +} from "./xml-parser"; +export { parseCamt053, parseCamt052 } from "./camt-parser"; +export { + buildDialogInitSegment, + buildDialogEndSegment, + buildSyncSegment, + buildAccountListSegment, + buildBalanceSegment, + buildAccountStatementSegment, + buildTanSegment, + buildTanSubmitSegment, +} from "./segments"; diff --git a/packages/fints/src/v4/segments/index.ts b/packages/fints/src/v4/segments/index.ts new file mode 100644 index 0000000..47be209 --- /dev/null +++ b/packages/fints/src/v4/segments/index.ts @@ -0,0 +1,197 @@ +/** + * FinTS 4.1 XML segment builders. + * + * These functions create the XML content for various FinTS 4.1 business segments. + */ +import { XmlSegment, xmlElement, escapeXml } from "../xml-builder"; +import { FINTS_VERSION, COUNTRY_CODE } from "../constants"; +import { PRODUCT_NAME, PRODUCT_VERSION } from "../../constants"; +import { SEPAAccount } from "../../types"; + +/** + * Build a DialogInit segment for initializing a dialog. + */ +export function buildDialogInitSegment(options: { + segNo: number; + blz: string; + name: string; + systemId: string; + productId?: string; + hbciVersion?: string; +}): XmlSegment { + const productId = options.productId || PRODUCT_NAME; + const hbciVersion = options.hbciVersion || FINTS_VERSION; + const body = + xmlElement("BLZ", options.blz) + + xmlElement("CountryCode", COUNTRY_CODE) + + xmlElement("CustomerID", escapeXml(options.name)) + + xmlElement("SystemID", escapeXml(options.systemId)) + + xmlElement("Product", xmlElement("Name", escapeXml(productId)) + xmlElement("Version", PRODUCT_VERSION)) + + xmlElement("HBCIVersion", hbciVersion); + + return { + type: "DialogInit", + version: 1, + segNo: options.segNo, + body, + }; +} + +/** + * Build a DialogEnd segment for terminating a dialog. + */ +export function buildDialogEndSegment(options: { segNo: number; dialogId: string }): XmlSegment { + return { + type: "DialogEnd", + version: 1, + segNo: options.segNo, + body: xmlElement("DialogID", escapeXml(options.dialogId)), + }; +} + +/** + * Build a Sync segment for synchronization (obtaining system ID, BPD, UPD). + */ +export function buildSyncSegment(options: { segNo: number; mode?: number }): XmlSegment { + const mode = options.mode ?? 0; + return { + type: "Sync", + version: 1, + segNo: options.segNo, + body: xmlElement("SyncMode", String(mode)), + }; +} + +/** + * Build an account list request segment (equivalent to HKSPA in v3). + */ +export function buildAccountListSegment(options: { segNo: number }): XmlSegment { + return { + type: "AccountList", + version: 1, + segNo: options.segNo, + body: xmlElement("AllAccounts", "true"), + }; +} + +/** + * Build a balance request segment (equivalent to HKSAL in v3). + */ +export function buildBalanceSegment(options: { segNo: number; version: number; account: SEPAAccount }): XmlSegment { + const accountXml = + xmlElement("IBAN", escapeXml(options.account.iban)) + + xmlElement("BIC", escapeXml(options.account.bic)) + + xmlElement("AccountNumber", escapeXml(options.account.accountNumber)) + + xmlElement("BLZ", escapeXml(options.account.blz)); + + return { + type: "Balance", + version: options.version, + segNo: options.segNo, + body: xmlElement("Account", accountXml), + }; +} + +/** + * Build an account statement request segment (HKCAZ - camt-based account statement). + */ +export function buildAccountStatementSegment(options: { + segNo: number; + version: number; + account: SEPAAccount; + startDate?: Date; + endDate?: Date; + camtFormat?: string; + touchdown?: string; +}): XmlSegment { + const accountXml = + xmlElement("IBAN", escapeXml(options.account.iban)) + + xmlElement("BIC", escapeXml(options.account.bic)) + + xmlElement("AccountNumber", escapeXml(options.account.accountNumber)) + + xmlElement("BLZ", escapeXml(options.account.blz)); + + let body = xmlElement("Account", accountXml); + + if (options.startDate) { + body += xmlElement("StartDate", formatDate(options.startDate)); + } + if (options.endDate) { + body += xmlElement("EndDate", formatDate(options.endDate)); + } + + const camtFormat = options.camtFormat || "urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"; + body += xmlElement("CamtFormat", camtFormat); + + if (options.touchdown) { + body += xmlElement("Touchdown", escapeXml(options.touchdown)); + } + + return { + type: "AccountStatement", + version: options.version, + segNo: options.segNo, + body, + }; +} + +/** + * Build a TAN request segment for v4.1. + */ +export function buildTanSegment(options: { + segNo: number; + version: number; + process: string; + segmentReference?: string; + medium?: string; + aref?: string; +}): XmlSegment { + let body = xmlElement("TANProcess", options.process); + + if (options.segmentReference) { + body += xmlElement("SegmentReference", escapeXml(options.segmentReference)); + } + if (options.medium) { + body += xmlElement("TANMedium", escapeXml(options.medium)); + } + if (options.aref) { + body += xmlElement("TransactionReference", escapeXml(options.aref)); + } + + return { + type: "TAN", + version: options.version, + segNo: options.segNo, + body, + }; +} + +/** + * Format a Date object as YYYY-MM-DD string. + */ +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Build a TAN submission segment for the two-step TAN flow (process 2). + * + * Use this after receiving a TAN challenge (return code `0030`) to submit the + * user-entered TAN. Include the TAN value itself in the message via `options.tan` + * in `dialog.send()`. + */ +export function buildTanSubmitSegment(options: { + segNo: number; + version: number; + transactionReference: string; +}): XmlSegment { + return { + type: "TAN", + version: options.version, + segNo: options.segNo, + body: + xmlElement("TANProcess", "2") + xmlElement("TransactionReference", escapeXml(options.transactionReference)), + }; +} diff --git a/packages/fints/src/v4/test-server/index.ts b/packages/fints/src/v4/test-server/index.ts new file mode 100644 index 0000000..d48826a --- /dev/null +++ b/packages/fints/src/v4/test-server/index.ts @@ -0,0 +1,12 @@ +/** + * FinTS 4.1 Test Server – public exports. + */ +export { MockBankServer } from "./mock-bank-server"; +export { + TEST_BANK, + TEST_USERS, + TEST_TAN_METHODS, + TEST_ACCOUNTS, + TEST_SEGMENT_CAPABILITIES, + buildTestCamt053, +} from "./test-data"; diff --git a/packages/fints/src/v4/test-server/mock-bank-server.ts b/packages/fints/src/v4/test-server/mock-bank-server.ts new file mode 100644 index 0000000..49cefa8 --- /dev/null +++ b/packages/fints/src/v4/test-server/mock-bank-server.ts @@ -0,0 +1,504 @@ +/** + * FinTS 4.1 Mock Bank Server + * + * A simulated FinTS 4.1 server that accepts XML requests and returns + * realistic XML responses with test data. Designed for integration testing + * of the FinTS 4.1 client without requiring a real bank connection. + * + * Features: + * - Full dialog lifecycle support (Sync → Init → Business → End) + * - PIN/TAN authentication validation + * - Account listing, balance queries, account statements (camt.053) + * - Realistic return codes per FinTS 4.1 specification + * - Pagination support via touchdown tokens + * - Error simulation (invalid PIN, unknown account, etc.) + * + * Usage in tests: + * ```typescript + * const server = new MockBankServer(); + * const client = new FinTS4Client({ + * blz: "76050101", + * name: "testuser", + * pin: "12345", + * url: "http://mock", // irrelevant – we inject the connection + * }); + * // Use server.createConnection() as the mock connection + * ``` + */ +import { XMLParser } from "fast-xml-parser"; +import { FinTS4Connection } from "../types"; +import { FINTS_NAMESPACE, FINTS_VERSION, COUNTRY_CODE } from "../constants"; +import { + TEST_BANK, + TEST_USERS, + TEST_TAN_METHODS, + TEST_ACCOUNTS, + TEST_SEGMENT_CAPABILITIES, + buildTestCamt053, +} from "./test-data"; + +/** + * State of a dialog in the mock server. + */ +interface DialogState { + dialogId: string; + userId: string; + authenticated: boolean; + msgNo: number; + systemId: string; +} + +/** + * A simulated FinTS 4.1 bank server for integration testing. + */ +export class MockBankServer { + /** Active dialogs keyed by dialog ID. */ + private dialogs = new Map(); + /** Counter for generating dialog IDs. */ + private nextDialogId = 1000; + /** Counter for generating system IDs. */ + private nextSystemId = 1; + /** All received requests (for test assertions). */ + public requestLog: string[] = []; + /** All sent responses (for test assertions). */ + public responseLog: string[] = []; + + private parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + parseAttributeValue: false, + parseTagValue: false, + trimValues: true, + isArray: (name: string) => ["Segment", "ReturnValue"].includes(name), + }); + + /** + * Create a FinTS4Connection that routes requests through this mock server. + */ + public createConnection(): FinTS4Connection { + return { + send: async (xmlRequest: string) => this.handleRequest(xmlRequest), + }; + } + + /** + * Reset the server state (clear all dialogs, logs, and user system IDs). + */ + public reset(): void { + this.dialogs.clear(); + this.requestLog = []; + this.responseLog = []; + this.nextDialogId = 1000; + this.nextSystemId = 1; + // Reset per-user system IDs so tests are fully independent + for (const userId of Object.keys(TEST_USERS)) { + TEST_USERS[userId].systemId = ""; + } + } + + /** + * Process an incoming FinTS 4.1 XML request and return a response. + */ + public async handleRequest(xmlRequest: string): Promise { + this.requestLog.push(xmlRequest); + + try { + const parsed = this.parser.parse(xmlRequest); + const msg = parsed.FinTSMessage || parsed; + const msgHead = msg.MsgHead || {}; + const msgBody = msg.MsgBody || {}; + const msgNo = parseInt(String(msgHead.MsgNo || "1"), 10); + const dialogId = String(msgHead.DialogID || "0"); + + // Extract segments + const segments = this.extractSegments(msgBody); + + // Extract credentials from security envelope + const credentials = this.extractCredentials(msgBody); + + // Determine the type of request from the first business segment + const segmentTypes = segments.map((s) => s.type); + let response: string; + + if (segmentTypes.includes("DialogInit") && segmentTypes.includes("Sync")) { + response = this.handleSync(msgNo, credentials); + } else if (segmentTypes.includes("DialogInit")) { + response = this.handleDialogInit(msgNo, dialogId, credentials); + } else if (segmentTypes.includes("DialogEnd")) { + response = this.handleDialogEnd(msgNo, dialogId); + } else if (segmentTypes.includes("AccountList")) { + response = this.handleAccountList(msgNo, dialogId); + } else if (segmentTypes.includes("Balance")) { + const balSeg = segments.find((s) => s.type === "Balance"); + response = this.handleBalance(msgNo, dialogId, balSeg?.body); + } else if (segmentTypes.includes("AccountStatement")) { + const stmtSeg = segments.find((s) => s.type === "AccountStatement"); + response = this.handleAccountStatement(msgNo, dialogId, stmtSeg?.body); + } else { + response = this.buildErrorResponse(msgNo, dialogId, "9010", "Unbekannter Geschäftsvorfall"); + } + + this.responseLog.push(response); + return response; + } catch (error) { + const errorResponse = this.buildErrorResponse( + 1, + "0", + "9010", + `Verarbeitung nicht möglich: ${(error as Error).message}`, + ); + this.responseLog.push(errorResponse); + return errorResponse; + } + } + + // ----------------------------------------------------------------------- + // Request handlers + // ----------------------------------------------------------------------- + + private handleSync(msgNo: number, credentials: { userId: string; pin: string }): string { + // Validate credentials + const authError = this.authenticate(credentials); + if (authError) return authError; + + const dialogId = `sync-${this.nextDialogId++}`; + const systemId = `SYS-${String(this.nextSystemId++).padStart(10, "0")}`; + + // Store the system ID for the user + if (TEST_USERS[credentials.userId]) { + TEST_USERS[credentials.userId].systemId = systemId; + } + + this.dialogs.set(dialogId, { + dialogId, + userId: credentials.userId, + authenticated: true, + msgNo: msgNo + 1, + systemId, + }); + + const returnValues = + this.buildReturnValue("0010", "Nachricht entgegengenommen") + + this.buildReturnValue("3920", "Zugelassene TAN-Verfahren für den Benutzer", "912") + + this.buildReturnValue("3920", "Zugelassene TAN-Verfahren für den Benutzer", "913"); + + const syncSegment = this.buildSegment("SyncRes", 1, 1, `${systemId}`); + const bpdSegment = this.buildBpdSegment(2); + const updSegment = this.buildUpdSegment(3, credentials.userId); + const tanMethodsSegment = this.buildTanMethodsSegment(4); + const capSegments = TEST_SEGMENT_CAPABILITIES.map((cap, i) => + this.buildSegment(cap.type, cap.version, 5 + i, ""), + ).join("\n"); + + return this.buildResponse( + msgNo, + dialogId, + `${returnValues}${syncSegment}${bpdSegment}${updSegment}${tanMethodsSegment}${capSegments}`, + ); + } + + private handleDialogInit( + msgNo: number, + _requestDialogId: string, + credentials: { userId: string; pin: string }, + ): string { + const authError = this.authenticate(credentials); + if (authError) return authError; + + const dialogId = `DLG-${this.nextDialogId++}`; + this.dialogs.set(dialogId, { + dialogId, + userId: credentials.userId, + authenticated: true, + msgNo: msgNo + 1, + systemId: TEST_USERS[credentials.userId]?.systemId || "0", + }); + + return this.buildResponse( + msgNo, + dialogId, + this.buildReturnValue("0010", "Nachricht entgegengenommen") + + this.buildReturnValue("0020", "Auftrag ausgeführt"), + ); + } + + private handleDialogEnd(msgNo: number, dialogId: string): string { + this.dialogs.delete(dialogId); + + return this.buildResponse(msgNo, "0", this.buildReturnValue("0100", "Dialog beendet")); + } + + private handleAccountList(msgNo: number, dialogId: string): string { + const dialog = this.dialogs.get(dialogId); + if (!dialog) { + return this.buildErrorResponse(msgNo, dialogId, "9800", "Dialogkontext ungültig"); + } + + const accountsXml = TEST_ACCOUNTS.map( + (acc) => ` + + ${acc.iban} + ${acc.bic} + ${acc.accountNumber} + ${acc.blz} + ${acc.ownerName} + ${acc.accountName} + ${acc.currency} + `, + ).join(""); + + const segBody = accountsXml; + const accountSeg = this.buildSegment("AccountList", 1, 3, segBody); + + return this.buildResponse( + msgNo, + dialogId, + this.buildReturnValue("0010", "Nachricht entgegengenommen") + + this.buildReturnValue("0020", "Auftrag ausgeführt") + + accountSeg, + ); + } + + private handleBalance(msgNo: number, dialogId: string, body?: Record): string { + const dialog = this.dialogs.get(dialogId); + if (!dialog) { + return this.buildErrorResponse(msgNo, dialogId, "9800", "Dialogkontext ungültig"); + } + + // Extract IBAN from the request + const iban = this.extractIban(body); + const account = TEST_ACCOUNTS.find((a) => a.iban === iban); + if (!account) { + return this.buildErrorResponse(msgNo, dialogId, "9010", `Konto ${iban || "unbekannt"} nicht gefunden`); + } + + const balanceXml = ` + + 13095.67 + CRDT + ${new Date().toISOString().slice(0, 10)} + + + 13095.67 + CRDT + ${new Date().toISOString().slice(0, 10)} + + 5000.00 + ${account.accountName}`; + + const balanceSeg = this.buildSegment("Balance", 7, 3, balanceXml); + + return this.buildResponse( + msgNo, + dialogId, + this.buildReturnValue("0010", "Nachricht entgegengenommen") + + this.buildReturnValue("0020", "Auftrag ausgeführt") + + balanceSeg, + ); + } + + private handleAccountStatement(msgNo: number, dialogId: string, body?: Record): string { + const dialog = this.dialogs.get(dialogId); + if (!dialog) { + return this.buildErrorResponse(msgNo, dialogId, "9800", "Dialogkontext ungültig"); + } + + const iban = this.extractIban(body); + const account = TEST_ACCOUNTS.find((a) => a.iban === iban); + if (!account) { + return this.buildErrorResponse(msgNo, dialogId, "9010", `Konto ${iban || "unbekannt"} nicht gefunden`); + } + + // Build camt.053 data + const camtXml = buildTestCamt053(account.iban, account.currency); + // Escape the camt XML for embedding in the FinTS response + const escapedCamt = camtXml + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + + const stmtSeg = this.buildSegment("AccountStatement", 2, 3, `${escapedCamt}`); + + return this.buildResponse( + msgNo, + dialogId, + this.buildReturnValue("0010", "Nachricht entgegengenommen") + + this.buildReturnValue("0020", "Auftrag ausgeführt") + + stmtSeg, + ); + } + + // ----------------------------------------------------------------------- + // Helper methods + // ----------------------------------------------------------------------- + + private authenticate(credentials: { userId: string; pin: string }): string | null { + const user = TEST_USERS[credentials.userId]; + if (!user) { + return this.buildErrorResponse(1, "0", "9931", "Zugang gesperrt – Benutzer unbekannt"); + } + if (credentials.pin !== user.pin) { + return this.buildErrorResponse(1, "0", "9340", "PIN ungültig"); + } + return null; + } + + private extractSegments( + msgBody: Record, + ): Array<{ type: string; version: number; body: Record }> { + const segments: Array<{ type: string; version: number; body: Record }> = []; + const rawSegments = msgBody.Segment; + if (!rawSegments) return segments; + + const segArr = Array.isArray(rawSegments) ? rawSegments : [rawSegments]; + for (const seg of segArr) { + const segObj = seg as Record; + const head = segObj.SegHead as Record | undefined; + if (head) { + segments.push({ + type: String(head.Type || ""), + version: parseInt(String(head.Version || "1"), 10), + body: (segObj.SegBody as Record) || {}, + }); + } + } + return segments; + } + + private extractCredentials(msgBody: Record): { userId: string; pin: string } { + const header = msgBody.SignatureHeader as Record | undefined; + const trailer = msgBody.SignatureTrailer as Record | undefined; + + return { + userId: String(header?.UserID || ""), + pin: String(trailer?.PIN || ""), + }; + } + + private extractIban(body?: Record): string | undefined { + if (!body) return undefined; + const account = body.Account as Record | undefined; + return account ? String(account.IBAN || "") : undefined; + } + + private buildResponse(msgNo: number, dialogId: string, bodyContent: string): string { + return ` + + + ${msgNo} + ${dialogId} + ${FINTS_VERSION} + + ${TEST_BANK.blz} + ${COUNTRY_CODE} + + + + ${bodyContent} + + ${msgNo} +`; + } + + private buildErrorResponse(msgNo: number, dialogId: string, code: string, message: string): string { + return this.buildResponse(msgNo, dialogId, this.buildReturnValue(code, message)); + } + + private buildReturnValue(code: string, message: string, parameter?: string): string { + let xml = `${code}${this.escapeXml(message)}`; + if (parameter) { + xml += `${this.escapeXml(parameter)}`; + } + xml += `\n`; + return xml; + } + + private buildSegment(type: string, version: number, segNo: number, body: string): string { + return ` + + + ${type} + ${version} + ${segNo} + + ${body} + `; + } + + private buildBpdSegment(segNo: number): string { + const painFormats = TEST_BANK.painFormats.map((f) => `${f}`).join(""); + const camtFormats = TEST_BANK.camtFormats.map((f) => `${f}`).join(""); + + return this.buildSegment( + "BPD", + 1, + segNo, + ` + ${this.escapeXml(TEST_BANK.bankName)} + ${TEST_BANK.bpdVersion} + ${TEST_BANK.bic} + ${FINTS_VERSION} + de + PIN_TAN + ${painFormats} + ${camtFormats} + `, + ); + } + + private buildUpdSegment(segNo: number, userId: string): string { + const user = TEST_USERS[userId]; + const accounts = TEST_ACCOUNTS.map( + (acc) => ` + + ${acc.iban} + ${acc.bic} + ${this.escapeXml(user?.name || "")} + ${this.escapeXml(acc.accountName)} + HKCAZ + HKSAL + HKSPA + `, + ).join(""); + + return this.buildSegment( + "UPD", + 1, + segNo, + ` + ${TEST_BANK.updVersion} + ${accounts} + `, + ); + } + + private buildTanMethodsSegment(segNo: number): string { + const methods = TEST_TAN_METHODS.map( + (m) => ` + + ${m.securityFunction} + ${m.tanProcess} + ${m.techId} + ${this.escapeXml(m.name)} + ${m.maxLengthInput} + ${m.allowedFormat} + ${m.cancellable} + ${m.decoupledMaxStatusRequests ? `${m.decoupledMaxStatusRequests}` : ""} + ${m.decoupledWaitBeforeFirstStatusRequest ? `${m.decoupledWaitBeforeFirstStatusRequest}` : ""} + ${m.decoupledWaitBetweenStatusRequests ? `${m.decoupledWaitBetweenStatusRequests}` : ""} + `, + ).join(""); + + return this.buildSegment("TANMethods", 7, segNo, methods); + } + + private escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } +} diff --git a/packages/fints/src/v4/test-server/test-data.ts b/packages/fints/src/v4/test-server/test-data.ts new file mode 100644 index 0000000..6a7e04b --- /dev/null +++ b/packages/fints/src/v4/test-server/test-data.ts @@ -0,0 +1,251 @@ +/** + * Test data for the FinTS 4.1 mock bank server. + * + * Contains realistic German banking test data following the FinTS 4.1 specification. + * All data is fictional – no real bank accounts or persons are referenced. + */ + +/** + * Bank configuration for the test server. + */ +export const TEST_BANK = { + blz: "76050101", + bankName: "Sparkasse Teststadt", + bic: "SSKNDE77XXX", + bpdVersion: 85, + updVersion: 3, + supportedHbciVersions: ["3.0", "4.1"], + supportedLanguages: ["de", "en"], + supportedSecurityMethods: ["PIN_TAN"], + painFormats: ["urn:iso:std:iso:20022:tech:xsd:pain.001.003.03", "urn:iso:std:iso:20022:tech:xsd:pain.002.003.03"], + camtFormats: ["urn:iso:std:iso:20022:tech:xsd:camt.053.001.02", "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02"], +}; + +/** + * Test users recognized by the mock server. + */ +export const TEST_USERS: Record = { + testuser: { + pin: "12345", + name: "Max Mustermann", + systemId: "", + }, + testuser2: { + pin: "54321", + name: "Erika Musterfrau", + systemId: "", + }, +}; + +/** + * TAN methods offered by the mock server. + */ +export const TEST_TAN_METHODS = [ + { + securityFunction: "912", + tanProcess: "2", + techId: "pushTAN", + name: "pushTAN 2.0", + maxLengthInput: 6, + allowedFormat: "1", + cancellable: true, + decoupledMaxStatusRequests: 60, + decoupledWaitBeforeFirstStatusRequest: 5, + decoupledWaitBetweenStatusRequests: 2, + }, + { + securityFunction: "913", + tanProcess: "1", + techId: "chipTAN", + name: "chipTAN QR", + maxLengthInput: 8, + allowedFormat: "0", + cancellable: false, + }, +]; + +/** + * Test accounts for the mock server. + */ +export const TEST_ACCOUNTS = [ + { + iban: "DE89370400440532013000", + bic: "SSKNDE77XXX", + accountNumber: "0532013000", + blz: "76050101", + ownerName: "Max Mustermann", + accountName: "Girokonto", + currency: "EUR", + }, + { + iban: "DE27100777770209299700", + bic: "SSKNDE77XXX", + accountNumber: "0209299700", + blz: "76050101", + ownerName: "Max Mustermann", + accountName: "Sparkonto", + currency: "EUR", + }, +]; + +/** + * Segment capabilities advertised by the mock server. + * These follow the FinTS 4.1 specification's parameter segments. + */ +export const TEST_SEGMENT_CAPABILITIES = [ + { type: "Balance", version: 7 }, + { type: "AccountStatement", version: 2 }, + { type: "AccountList", version: 1 }, + { type: "TAN", version: 7 }, + { type: "HISALS", version: 7 }, + { type: "HICAZS", version: 2 }, + { type: "HITANS", version: 7 }, +]; + +/** + * Build a realistic camt.053.001.02 XML document with German banking test data. + */ +export function buildTestCamt053(iban: string, currency = "EUR", startDate?: Date, endDate?: Date): string { + const now = new Date(); + const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1); + const end = endDate || now; + const fmtDate = (d: Date) => d.toISOString().slice(0, 10); + + return ` + + + + MSG-${fmtDate(now)}-001 + ${now.toISOString()} + + + STMT-${fmtDate(start)}-${fmtDate(end)} + 42 + ${now.toISOString()} + + ${iban} + ${currency} + + ${TEST_BANK.bic} + + + + PRCD + 12345.67 + CRDT +
${fmtDate(start)}
+
+ + CLBD + 13095.67 + CRDT +
${fmtDate(end)}
+
+ + ENT-001 + 2500.00 + CRDT + BOOK +
${fmtDate(start)}
+
${fmtDate(start)}
+ PMNTRCDTESCT + + + + GEHALT-${fmtDate(start)} + MNDT-GEHALT-001 + + + Arbeitgeber Test GmbH + DE44500105175407324931 + + + INGDDEFFXXX + + Gehalt ${start.toLocaleString("de-DE", { month: "long", year: "numeric" })} + + +
+ + ENT-002 + 850.00 + DBIT + BOOK +
${fmtDate(start)}
+
${fmtDate(start)}
+ PMNTICDTESCT + + + MIETE-${fmtDate(start)} + + Immobilien Verwaltung GmbH + DE27100777770209299700 + + Miete Wohnung 4B ${start.toLocaleString("de-DE", { month: "long", year: "numeric" })} + + +
+ + ENT-003 + 45.99 + DBIT + BOOK +
${fmtDate(start)}
+
${fmtDate(start)}
+ PMNTICDTESCT + + + STROM-${fmtDate(start)}MNDT-STROM-001 + + Stadtwerke Teststadt + DE91100000000123456789 + + Abschlag Strom/Gas Kundennr. 4711 + + +
+ + ENT-004 + 1100.00 + CRDT + BOOK +
${fmtDate(end)}
+
${fmtDate(end)}
+ PMNTRCDTESCT + + + FREIBERUF-${fmtDate(end)} + + Consulting Kunde AG + DE75512108001245126199 + + + SOLADEST600 + + Rechnung RE-2024-042 Beratungsleistung + + +
+ + ENT-005 + 54.01 + DBIT + BOOK +
${fmtDate(end)}
+
${fmtDate(end)}
+ PMNTICDTESCT + + + EINKAUF-${fmtDate(end)} + + REWE Markt GmbH + DE86200800000970375700 + + REWE SAGT DANKE 54712 + + +
+
+
+
`; +} diff --git a/packages/fints/src/v4/types.ts b/packages/fints/src/v4/types.ts new file mode 100644 index 0000000..0090707 --- /dev/null +++ b/packages/fints/src/v4/types.ts @@ -0,0 +1,329 @@ +/** + * Types specific to the FinTS 4.1 protocol. + */ +import { SEPAAccount, Balance } from "../types"; +import { TanMethod } from "../tan-method"; + +/** + * A TAN challenge issued by the server in a two-step TAN flow. + * The server sends this when a transaction needs additional authentication. + */ +export interface TanChallenge { + /** The text of the challenge to display to the user. */ + challengeText?: string; + /** The server-issued transaction reference needed for TAN submission. */ + transactionReference: string; + /** Optional HHD-encoded challenge data for chip-TAN devices. */ + challengeHhd?: string; + /** Name of the TAN method that issued the challenge. */ + tanMethodName?: string; + /** Time in seconds until the challenge expires (if provided by the bank). */ + challengeValidSeconds?: number; +} + +/** + * Callback invoked when the server requires a TAN to proceed. + * The implementation should show the challenge to the user and return the TAN they enter. + * + * @example + * ```typescript + * const tanCallback: TanCallback = async (challenge) => { + * console.log(challenge.challengeText); // "Please confirm: Transfer 100 EUR" + * return await promptUser("Enter TAN: "); // user types their TAN + * }; + * ``` + */ +export type TanCallback = (challenge: TanChallenge) => Promise; + +/** + * Configuration for a FinTS 4.1 dialog. + */ +export interface FinTS4DialogConfig { + /** The bank's identification number (Bankleitzahl). */ + blz: string; + /** The username or identification number. */ + name: string; + /** The PIN code or password. */ + pin: string; + /** System ID for the client. */ + systemId: string; + /** Product registration ID. */ + productId?: string; + /** + * Callback invoked when the server issues a TAN challenge. + * If not provided and a TAN is required, a `FinTS4TanRequiredError` is thrown. + */ + tanCallback?: TanCallback; +} + +/** + * Configuration for the FinTS 4.1 client. + */ +export interface FinTS4ClientConfig { + /** The bank's identification number (Bankleitzahl). */ + blz: string; + /** The username or identification number. */ + name: string; + /** The PIN code or password. */ + pin: string; + /** The URL to reach the FinTS server. */ + url: string; + /** Product registration ID. */ + productId?: string; + /** If set to true, will log all requests and responses. */ + debug?: boolean; + /** Timeout in milliseconds for HTTP requests. */ + timeout?: number; + /** Maximum number of retry attempts. */ + maxRetries?: number; + /** Base delay for retry backoff in milliseconds. */ + retryDelay?: number; + /** + * Callback invoked when the server issues a TAN challenge. + * If not provided and a TAN is required, a `FinTS4TanRequiredError` is thrown. + */ + tanCallback?: TanCallback; + /** + * Additional options passed directly to the underlying `fetch()` call. + * Useful for providing a custom TLS agent in Node.js: + * ```typescript + * import https from "https"; + * const agent = new https.Agent({ rejectUnauthorized: false }); + * const client = new FinTS4Client({ ..., fetchOptions: { agent } }); + * ``` + */ + fetchOptions?: Record; + /** + * TLS configuration for bank-specific certificate requirements (Node.js only). + * Use `createTlsAgent(tlsOptions)` to create an agent and pass it via `fetchOptions`. + */ + tlsOptions?: FinTS4TlsOptions; + /** + * Preferred HBCI protocol version string (e.g. "4.1", "4.0"). + * Defaults to "4.1". The client will negotiate downward if the bank rejects the preferred version. + */ + preferredHbciVersion?: string; +} + +/** + * TLS configuration for FinTS 4.1 connections (Node.js only). + */ +export interface FinTS4TlsOptions { + /** + * Whether to reject connections with unauthorized (e.g. self-signed) certificates. + * Defaults to `true`. Set to `false` only for testing — never in production. + */ + rejectUnauthorized?: boolean; + /** + * Custom CA certificate(s) as PEM-encoded strings. + * Use this when the bank uses a certificate signed by a private CA. + */ + ca?: string | string[]; +} + +/** + * The message header for a FinTS 4.1 XML message. + */ +export interface MsgHead { + /** Message number within the dialog. */ + msgNo: number; + /** The unique dialog identifier. */ + dialogId: string; + /** The HBCI/FinTS protocol version. */ + hbciVersion: string; +} + +/** + * The message tail for a FinTS 4.1 XML message. + */ +export interface MsgTail { + /** Message number (must match MsgHead). */ + msgNo: number; +} + +/** + * A parsed FinTS 4.1 XML response. + */ +export interface FinTS4Response { + /** The dialog ID from the response. */ + dialogId: string; + /** Message number from the response. */ + msgNo: number; + /** Whether the response indicates success. */ + success: boolean; + /** Return codes and messages. */ + returnValues: FinTS4ReturnValue[]; + /** System ID (from sync responses). */ + systemId?: string; + /** Bank parameter data. */ + bpd?: BankParameterData; + /** User parameter data. */ + upd?: UserParameterData; + /** TAN methods supported by the server. */ + tanMethods?: TanMethod[]; + /** SEPA accounts. */ + accounts?: SEPAAccount[]; + /** Balance data. */ + balance?: Balance; + /** Raw camt XML data for statement parsing. */ + camtData?: string; + /** Supported HBCI versions. */ + supportedHbciVersions?: string[]; + /** Supported segment versions (segment type -> max version). */ + segmentVersions?: Map; + /** Pain formats supported by the server. */ + painFormats?: string[]; + /** Supported camt formats. */ + supportedCamtFormats?: string[]; + /** Touchdown token for pagination. */ + touchdown?: string; + /** Raw parsed XML object for further processing. */ + rawXml?: unknown; + /** + * TAN challenge from the server. Present when the server requires strong customer + * authentication (return code `0030`). Pass to the user and submit via `tanCallback`. + */ + tanChallenge?: TanChallenge; + /** + * Whether the server requires a TAN to proceed (return code `0030`). + * When `true` and `tanChallenge` is present, the dialog will automatically + * handle the TAN flow if `tanCallback` is configured. + */ + tanRequired?: boolean; +} + +/** + * A single return value from a FinTS 4.1 response. + */ +export interface FinTS4ReturnValue { + /** The return code (e.g., "0010" for success). */ + code: string; + /** Human-readable message. */ + message: string; + /** Whether this is an error code. */ + isError: boolean; + /** Additional parameters. */ + parameters?: string[]; +} + +/** + * Bank Parameter Data (BPD) from a FinTS 4.1 synchronization response. + */ +export interface BankParameterData { + /** The bank's name. */ + bankName?: string; + /** BPD version number. */ + bpdVersion?: number; + /** Supported HBCI/FinTS versions. */ + supportedVersions?: string[]; + /** Maximum number of business transactions per message. */ + maxTransactionsPerMsg?: number; + /** Supported languages. */ + supportedLanguages?: string[]; + /** Supported security methods. */ + supportedSecurityMethods?: string[]; + /** Segment capabilities (segment type -> max version). */ + segmentVersions?: Map; + /** Supported pain formats. */ + painFormats?: string[]; + /** Supported camt formats. */ + camtFormats?: string[]; + /** Minimum number of signatures required for balance queries. */ + minSignaturesBalance?: number; + /** Minimum number of signatures required for account statements. */ + minSignaturesStatement?: number; + /** Negotiated HBCI version (highest version supported by both client and server). */ + negotiatedVersion?: string; +} + +/** + * User Parameter Data (UPD) from a FinTS 4.1 response. + */ +export interface UserParameterData { + /** UPD version number. */ + updVersion?: number; + /** User's accounts with access permissions. */ + accounts?: UserAccount[]; +} + +/** + * An account as described in User Parameter Data. + */ +export interface UserAccount { + /** IBAN of the account. */ + iban: string; + /** BIC of the account. */ + bic?: string; + /** Account number (legacy). */ + accountNumber?: string; + /** BLZ (legacy). */ + blz?: string; + /** Account owner name. */ + ownerName?: string; + /** Account name/description. */ + accountName?: string; + /** Allowed transaction types. */ + allowedTransactions?: string[]; +} + +/** + * A parsed camt.053 statement entry. + */ +export interface CamtEntry { + /** Entry reference. */ + entryReference?: string; + /** Amount of the entry. */ + amount: number; + /** Currency code. */ + currency: string; + /** Credit or Debit indicator. */ + creditDebitIndicator: "CRDT" | "DBIT"; + /** Booking date. */ + bookingDate?: Date; + /** Value date. */ + valueDate?: Date; + /** Remittance information / payment purpose. */ + remittanceInformation?: string; + /** Creditor/Debtor name. */ + counterpartyName?: string; + /** Creditor/Debtor IBAN. */ + counterpartyIban?: string; + /** Creditor/Debtor BIC. */ + counterpartyBic?: string; + /** End-to-end reference. */ + endToEndReference?: string; + /** Mandate reference. */ + mandateReference?: string; + /** Bank transaction code. */ + bankTransactionCode?: string; +} + +/** + * A parsed camt.053 statement. + */ +export interface CamtStatement { + /** Statement ID. */ + id: string; + /** Account IBAN. */ + iban?: string; + /** Statement creation date. */ + creationDate?: Date; + /** Opening balance. */ + openingBalance?: number; + /** Closing balance. */ + closingBalance?: number; + /** Currency. */ + currency?: string; + /** Statement entries (transactions). */ + entries: CamtEntry[]; +} + +/** + * Connection interface for FinTS 4.1. + */ +export interface FinTS4Connection { + /** + * Send an XML request and receive an XML response. + */ + send(xmlRequest: string): Promise; +} diff --git a/packages/fints/src/v4/xml-builder.ts b/packages/fints/src/v4/xml-builder.ts new file mode 100644 index 0000000..6041ede --- /dev/null +++ b/packages/fints/src/v4/xml-builder.ts @@ -0,0 +1,200 @@ +/** + * XML message builder for FinTS 4.1 protocol. + * + * Constructs well-formed XML messages conforming to the FinTS 4.1 specification. + */ +import { FINTS_NAMESPACE, FINTS_VERSION, XML_DECLARATION, COUNTRY_CODE } from "./constants"; +import { PRODUCT_NAME, PRODUCT_VERSION } from "../constants"; + +/** + * Options for building a FinTS 4.1 XML segment. + */ +export interface XmlSegment { + /** Segment type identifier (e.g., "DialogInit", "Sync", "HKCAZ"). */ + type: string; + /** Segment version. */ + version: number; + /** Segment number within the message. */ + segNo: number; + /** XML content of the segment body. */ + body: string; +} + +/** + * Options for building a complete FinTS 4.1 XML message. + */ +export interface XmlMessageOptions { + /** Message number. */ + msgNo: number; + /** Dialog ID (use "0" for initial messages). */ + dialogId: string; + /** Business segments in the message body. */ + segments: XmlSegment[]; + /** BLZ (bank code). */ + blz: string; + /** User name / customer ID. */ + name: string; + /** PIN for authentication. */ + pin?: string; + /** System ID. */ + systemId: string; + /** Product ID. */ + productId?: string; + /** TAN if required. */ + tan?: string; + /** Security function to use. */ + securityFunction?: string; + /** + * HBCI protocol version string used in MsgHead (e.g. "4.1", "4.0"). + * Defaults to the module constant FINTS_VERSION when omitted. + */ + hbciVersion?: string; +} + +/** + * Escape special XML characters in a string value. + */ +export function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Build a FinTS 4.1 XML element with the given tag, attributes, and content. + */ +export function xmlElement(tag: string, content: string, attributes?: Record): string { + const attrStr = attributes + ? Object.entries(attributes) + .map(([k, v]) => ` ${k}="${escapeXml(v)}"`) + .join("") + : ""; + return `<${tag}${attrStr}>${content}`; +} + +/** + * Build a self-closing XML element. + */ +export function xmlEmptyElement(tag: string, attributes?: Record): string { + const attrStr = attributes + ? Object.entries(attributes) + .map(([k, v]) => ` ${k}="${escapeXml(v)}"`) + .join("") + : ""; + return `<${tag}${attrStr}/>`; +} + +/** + * Build the MsgHead (message header) for a FinTS 4.1 message. + */ +export function buildMsgHead(options: { + msgNo: number; + dialogId: string; + blz: string; + systemId: string; + productId?: string; + hbciVersion?: string; +}): string { + const productId = options.productId || PRODUCT_NAME; + const version = options.hbciVersion || FINTS_VERSION; + return xmlElement( + "MsgHead", + xmlElement("MsgNo", String(options.msgNo)) + + xmlElement("DialogID", escapeXml(options.dialogId)) + + xmlElement("HBCIVersion", version) + + xmlElement( + "Initiator", + xmlElement("BLZ", options.blz) + + xmlElement("CountryCode", COUNTRY_CODE) + + xmlElement("SystemID", escapeXml(options.systemId)), + ) + + xmlElement("Product", xmlElement("Name", escapeXml(productId)) + xmlElement("Version", PRODUCT_VERSION)), + ); +} + +/** + * Build the MsgTail (message trailer) for a FinTS 4.1 message. + */ +export function buildMsgTail(msgNo: number): string { + return xmlElement("MsgTail", xmlElement("MsgNo", String(msgNo))); +} + +/** + * Build the security envelope (SignatureHeader and SignatureTrailer) for PIN/TAN. + */ +export function buildSecurityEnvelope(options: { + pin?: string; + tan?: string; + name: string; + systemId: string; + securityFunction?: string; +}): { header: string; trailer: string } { + const secFunc = options.securityFunction || "999"; + const header = xmlElement( + "SignatureHeader", + xmlElement("SecurityFunction", secFunc) + + xmlElement("SecurityMethod", "PIN_TAN") + + xmlElement("UserID", escapeXml(options.name)) + + xmlElement("SystemID", escapeXml(options.systemId)), + ); + + let trailerContent = ""; + if (options.pin) { + trailerContent += xmlElement("PIN", escapeXml(options.pin)); + } + if (options.tan) { + trailerContent += xmlElement("TAN", escapeXml(options.tan)); + } + const trailer = xmlElement("SignatureTrailer", trailerContent); + return { header, trailer }; +} + +/** + * Wrap a segment's body content in a Segment XML element. + */ +export function buildSegment(segment: XmlSegment): string { + return xmlElement( + "Segment", + xmlElement( + "SegHead", + xmlElement("Type", segment.type) + + xmlElement("Version", String(segment.version)) + + xmlElement("SegNo", String(segment.segNo)), + ) + xmlElement("SegBody", segment.body), + ); +} + +/** + * Build a complete FinTS 4.1 XML message. + */ +export function buildMessage(options: XmlMessageOptions): string { + const msgHead = buildMsgHead({ + msgNo: options.msgNo, + dialogId: options.dialogId, + blz: options.blz, + systemId: options.systemId, + productId: options.productId, + hbciVersion: options.hbciVersion, + }); + + const security = buildSecurityEnvelope({ + pin: options.pin, + tan: options.tan, + name: options.name, + systemId: options.systemId, + securityFunction: options.securityFunction, + }); + + const segmentsXml = options.segments.map(buildSegment).join(""); + + const msgBody = xmlElement("MsgBody", security.header + segmentsXml + security.trailer); + + const msgTail = buildMsgTail(options.msgNo); + + const content = msgHead + msgBody + msgTail; + + return XML_DECLARATION + xmlElement("FinTSMessage", content, { xmlns: FINTS_NAMESPACE }); +} diff --git a/packages/fints/src/v4/xml-parser.ts b/packages/fints/src/v4/xml-parser.ts new file mode 100644 index 0000000..8e22285 --- /dev/null +++ b/packages/fints/src/v4/xml-parser.ts @@ -0,0 +1,498 @@ +/** + * XML response parser for FinTS 4.1 protocol. + * + * Parses XML responses from FinTS 4.1 servers into structured objects. + */ +import { XMLParser } from "fast-xml-parser"; +import { + FinTS4Response, + FinTS4ReturnValue, + BankParameterData, + UserParameterData, + UserAccount, + TanChallenge, +} from "./types"; +import { TanMethod } from "../tan-method"; +import { SEPAAccount, Balance } from "../types"; + +/** + * Parser options for fast-xml-parser. + */ +const parserOptions = { + ignoreAttributes: false, + attributeNamePrefix: "@_", + parseAttributeValue: false, + parseTagValue: false, + trimValues: true, + isArray: (name: string) => { + // These elements can occur multiple times + return [ + "Segment", + "ReturnValue", + "Parameter", + "Account", + "TANMethod", + "Version", + "Language", + "SecurityMethod", + "PainFormat", + "CamtFormat", + "Transaction", + "Entry", + "Ntry", + "Stmt", + ].includes(name); + }, +}; + +/** + * Create a configured XML parser instance. + */ +function createParser(): XMLParser { + return new XMLParser(parserOptions); +} + +/** + * Safely access a nested XML property using a dot-separated path. + */ +export function getXmlValue(obj: unknown, path: string): unknown { + const parts = path.split("."); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * Get a string value from an XML object at the given path. + */ +export function getXmlString(obj: unknown, path: string): string | undefined { + const value = getXmlValue(obj, path); + if (value == null) return undefined; + return String(value); +} + +/** + * Get a number value from an XML object at the given path. + */ +export function getXmlNumber(obj: unknown, path: string): number | undefined { + const value = getXmlValue(obj, path); + if (value == null) return undefined; + const num = Number(value); + return isNaN(num) ? undefined : num; +} + +/** + * Ensure a value is an array. + */ +export function ensureArray(value: T | T[] | undefined | null): T[] { + if (value == null) return []; + return Array.isArray(value) ? value : [value]; +} + +/** + * Parse return values from a FinTS 4.1 XML response. + */ +function parseReturnValues(xml: unknown): FinTS4ReturnValue[] { + const returnValues = ensureArray(getXmlValue(xml, "ReturnValue") as Record[]); + return returnValues.map((rv) => ({ + code: getXmlString(rv, "Code") || "", + message: getXmlString(rv, "Message") || "", + isError: isErrorCode(getXmlString(rv, "Code") || ""), + parameters: ensureArray(getXmlValue(rv, "Parameter") as string[]), + })); +} + +/** + * Determine if a FinTS return code indicates an error. + * Codes starting with "9" are errors, "3" are warnings, "0" are informational. + */ +export function isErrorCode(code: string): boolean { + return code.startsWith("9"); +} + +/** + * Parse TAN methods from a FinTS 4.1 XML response. + */ +function parseTanMethods(xml: unknown): TanMethod[] { + const tanMethods = ensureArray(getXmlValue(xml, "TANMethod") as Record[]); + return tanMethods.map((tm) => ({ + securityFunction: getXmlString(tm, "SecurityFunction") || "", + tanProcess: getXmlString(tm, "TANProcess") || "1", + techId: getXmlString(tm, "TechID") || "", + name: getXmlString(tm, "Name") || "", + maxLengthInput: getXmlNumber(tm, "MaxLengthInput") || 6, + allowedFormat: getXmlString(tm, "AllowedFormat") || "0", + tanListNumberRequired: getXmlString(tm, "TANListNumberRequired") === "true", + cancellable: getXmlString(tm, "Cancellable") === "true", + decoupledMaxStatusRequests: getXmlNumber(tm, "DecoupledMaxStatusRequests"), + decoupledWaitBeforeFirstStatusRequest: getXmlNumber(tm, "DecoupledWaitBeforeFirstStatusRequest"), + decoupledWaitBetweenStatusRequests: getXmlNumber(tm, "DecoupledWaitBetweenStatusRequests"), + })); +} + +/** + * Parse SEPA accounts from a FinTS 4.1 XML response. + */ +function parseAccounts(xml: unknown): SEPAAccount[] { + const accounts = ensureArray(getXmlValue(xml, "Account") as Record[]); + return accounts.map((acct) => ({ + iban: getXmlString(acct, "IBAN") || "", + bic: getXmlString(acct, "BIC") || "", + accountNumber: getXmlString(acct, "AccountNumber") || "", + subAccount: getXmlString(acct, "SubAccount"), + blz: getXmlString(acct, "BLZ") || "", + accountOwnerName: getXmlString(acct, "OwnerName"), + accountName: getXmlString(acct, "AccountName"), + })); +} + +/** + * Parse a single balance sub-element (e.g. `` or ``). + * + * Returns the numeric amount (positive for CRDT, negative for DBIT) and currency, + * or undefined if the element is missing. + */ +function parseBalanceElement( + body: Record, + elementName: string, +): { amount: number; currency: string } | undefined { + const elem = getXmlValue(body, elementName) as Record | undefined; + if (!elem) return undefined; + + // Amount may be a plain number or wrapped in an element with a Ccy attribute + const amountRaw = getXmlValue(elem, "Amount"); + let amount: number; + let currency = "EUR"; + + if (typeof amountRaw === "object" && amountRaw !== null) { + const amtObj = amountRaw as Record; + amount = Number(amtObj["#text"] ?? amtObj["Amount"] ?? 0); + currency = String(amtObj["@_Ccy"] ?? amtObj["Ccy"] ?? "EUR"); + } else { + amount = Number(amountRaw ?? 0); + // Currency may live as a sibling attribute + currency = getXmlString(elem, "@_Ccy") ?? getXmlString(elem, "Ccy") ?? "EUR"; + } + + const isDebit = String(getXmlValue(elem, "CdtDbtInd") ?? "CRDT").toUpperCase() === "DBIT"; + return { amount: isDebit ? -Math.abs(amount) : Math.abs(amount), currency }; +} + +/** + * Parse balance data from a FinTS 4.1 Balance segment body. + * + * The server may return ``, ``, and + * `` sub-elements. Returns undefined when the body is absent. + */ +export function parseBalance(xml: unknown, account: SEPAAccount): Balance | undefined { + const body = xml as Record | undefined; + if (!body) return undefined; + + const booked = parseBalanceElement(body, "BookedBalance"); + const available = parseBalanceElement(body, "AvailableBalance"); + + if (!booked) return undefined; + + const creditLimit = Number(getXmlValue(body, "CreditLimit") ?? 0); + const productName = getXmlString(body, "AccountName") ?? ""; + + return { + account, + bookedBalance: booked.amount, + availableBalance: available?.amount ?? booked.amount, + currency: booked.currency, + creditLimit, + pendingBalance: 0, + productName, + }; +} + +/** + * Parse Bank Parameter Data from a FinTS 4.1 XML response. + * + * Handles multiple structures used by different banks: + * - Nested `` element inside a segment body + * - Flat element names with varying capitalisation + */ +function parseBPD(xml: unknown): BankParameterData | undefined { + const bpd = getXmlValue(xml, "BPD") as Record | undefined; + if (!bpd) return undefined; + + const segmentVersions = new Map(); + const segments = ensureArray(getXmlValue(bpd, "Segment") as Record[]); + for (const seg of segments) { + const type = getXmlString(seg, "Type"); + const version = getXmlNumber(seg, "Version"); + if (type && version != null) { + const existing = segmentVersions.get(type) || 0; + if (version > existing) { + segmentVersions.set(type, version); + } + } + } + + // maxTransactionsPerMsg — banks use various element names + const maxTx = + getXmlNumber(bpd, "MaxTransactions") ?? + getXmlNumber(bpd, "MaxTransactionsPerMsg") ?? + getXmlNumber(bpd, "MaxBusinessTransactions") ?? + getXmlNumber(bpd, "maxTransactions"); + + // Minimum signatures — may appear under dedicated sub-elements + const balanceParams = getXmlValue(bpd, "BalanceParams") as Record | undefined; + const stmtParams = getXmlValue(bpd, "StatementParams") as Record | undefined; + + const minSigBalance = + getXmlNumber(bpd, "MinSignaturesBalance") ?? + (balanceParams ? getXmlNumber(balanceParams, "MinSignatures") : undefined); + const minSigStatement = + getXmlNumber(bpd, "MinSignaturesStatement") ?? + (stmtParams ? getXmlNumber(stmtParams, "MinSignatures") : undefined); + + return { + bankName: getXmlString(bpd, "BankName"), + bpdVersion: getXmlNumber(bpd, "BPDVersion") ?? getXmlNumber(bpd, "BpdVersion"), + supportedVersions: ensureArray(getXmlValue(bpd, "Version") as string[]).map(String), + maxTransactionsPerMsg: maxTx, + supportedLanguages: ensureArray(getXmlValue(bpd, "Language") as string[]).map(String), + supportedSecurityMethods: ensureArray(getXmlValue(bpd, "SecurityMethod") as string[]).map(String), + segmentVersions, + painFormats: ensureArray(getXmlValue(bpd, "PainFormat") as string[]).map(String), + camtFormats: ensureArray(getXmlValue(bpd, "CamtFormat") as string[]).map(String), + minSignaturesBalance: minSigBalance, + minSignaturesStatement: minSigStatement, + }; +} + +/** + * Parse User Parameter Data from a FinTS 4.1 XML response. + */ +function parseUPD(xml: unknown): UserParameterData | undefined { + const upd = getXmlValue(xml, "UPD") as Record | undefined; + if (!upd) return undefined; + + const accounts = ensureArray(getXmlValue(upd, "Account") as Record[]); + const userAccounts: UserAccount[] = accounts.map((acct) => ({ + iban: getXmlString(acct, "IBAN") || "", + bic: getXmlString(acct, "BIC"), + accountNumber: getXmlString(acct, "AccountNumber"), + blz: getXmlString(acct, "BLZ"), + ownerName: getXmlString(acct, "OwnerName"), + accountName: getXmlString(acct, "AccountName"), + allowedTransactions: ensureArray(getXmlValue(acct, "Transaction") as string[]).map(String), + })); + + return { + updVersion: getXmlNumber(upd, "UPDVersion"), + accounts: userAccounts, + }; +} + +/** + * Parse a segment from the response. + */ +function parseSegmentVersions(msgBody: unknown): Map { + const versions = new Map(); + const segments = ensureArray(getXmlValue(msgBody, "Segment") as Record[]); + for (const seg of segments) { + const segHead = getXmlValue(seg, "SegHead") as Record | undefined; + if (segHead) { + const type = getXmlString(segHead, "Type"); + const version = getXmlNumber(segHead, "Version"); + if (type && version != null) { + const existing = versions.get(type) || 0; + if (version > existing) { + versions.set(type, version); + } + } + } + } + return versions; +} + +/** + * Find a segment of a specific type in the message body. + */ +export function findSegment(msgBody: unknown, segmentType: string): Record | undefined { + const segments = ensureArray(getXmlValue(msgBody, "Segment") as Record[]); + return segments.find((seg) => { + const segHead = getXmlValue(seg, "SegHead") as Record | undefined; + return segHead && getXmlString(segHead, "Type") === segmentType; + }); +} + +/** + * Find all segments of a specific type in the message body. + */ +export function findSegments(msgBody: unknown, segmentType: string): Record[] { + const segments = ensureArray(getXmlValue(msgBody, "Segment") as Record[]); + return segments.filter((seg) => { + const segHead = getXmlValue(seg, "SegHead") as Record | undefined; + return segHead && getXmlString(segHead, "Type") === segmentType; + }); +} + +/** + * FinTS return code indicating a TAN is required before the order can proceed. + * The server sends this together with a challenge that the user must respond to. + */ +const RETURN_CODE_TAN_REQUIRED = "0030"; + +/** + * Parse a TAN challenge from a response segment. + * Returns undefined if the segment body does not contain the expected challenge fields. + */ +function parseTanChallenge(segBody: unknown, tanMethods?: Array<{ name?: string }>): TanChallenge | undefined { + const body = segBody as Record | undefined; + if (!body) return undefined; + + const transactionReference = + getXmlString(body, "TransactionReference") || + getXmlString(body, "ARef") || + getXmlString(body, "OrderReference"); + + if (!transactionReference) return undefined; + + return { + transactionReference, + challengeText: + getXmlString(body, "ChallengeText") || getXmlString(body, "Challenge") || getXmlString(body, "OrderInfo"), + challengeHhd: getXmlString(body, "ChallengeHHD") || getXmlString(body, "ChallengeHHDUC"), + tanMethodName: getXmlString(body, "TANMedium") || tanMethods?.[0]?.name, + challengeValidSeconds: getXmlNumber(body, "ChallengeValidSeconds"), + }; +} + +/** + * Parse a complete FinTS 4.1 XML response string. + * + * @param xmlString The raw XML response from the server. + * @param account Optional account context used to populate the `balance` field + * when the response contains a `` segment. + */ +export function parseResponse(xmlString: string, account?: SEPAAccount): FinTS4Response { + const parser = createParser(); + const parsed = parser.parse(xmlString); + + const fintsMsg = parsed.FinTSMessage || parsed; + const msgHead = fintsMsg.MsgHead || {}; + const msgBody = fintsMsg.MsgBody || {}; + + const dialogId = getXmlString(msgHead, "DialogID") || "0"; + const msgNo = getXmlNumber(msgHead, "MsgNo") || 0; + + // Parse return values from all sources + const returnValues = [...parseReturnValues(msgBody), ...parseReturnValues(fintsMsg)]; + + const success = !returnValues.some((rv) => rv.isError); + + // Detect TAN requirement (code 0030) + const tanRequired = returnValues.some((rv) => rv.code === RETURN_CODE_TAN_REQUIRED); + + // Parse system ID from sync response + const syncSegment = findSegment(msgBody, "SyncRes"); + const systemId = syncSegment + ? getXmlString(getXmlValue(syncSegment, "SegBody") as Record, "SystemID") + : undefined; + + // Parse BPD + const bpdSegment = findSegment(msgBody, "BPD"); + const bpd = bpdSegment ? parseBPD(getXmlValue(bpdSegment, "SegBody")) : undefined; + + // Parse UPD + const updSegment = findSegment(msgBody, "UPD"); + const upd = updSegment ? parseUPD(getXmlValue(updSegment, "SegBody")) : undefined; + + // Parse TAN methods + const tanMethodsSegment = findSegment(msgBody, "TANMethods"); + const tanMethodsList = tanMethodsSegment ? parseTanMethods(getXmlValue(tanMethodsSegment, "SegBody")) : undefined; + + // Parse accounts + const accountsSegment = findSegment(msgBody, "AccountList"); + const accounts = accountsSegment ? parseAccounts(getXmlValue(accountsSegment, "SegBody")) : undefined; + + // Parse camt data + const statementSegment = findSegment(msgBody, "AccountStatement"); + const camtData = statementSegment + ? getXmlString(getXmlValue(statementSegment, "SegBody") as Record, "CamtData") + : undefined; + + // Parse balance data (requires the account from the caller) + const balanceSegment = findSegment(msgBody, "Balance"); + const balanceSegBody = balanceSegment ? getXmlValue(balanceSegment, "SegBody") : undefined; + + // Parse segment versions from the response + const segmentVersions = parseSegmentVersions(msgBody); + + // Parse BPD segment versions into the response + if (bpd?.segmentVersions) { + for (const [type, version] of bpd.segmentVersions) { + segmentVersions.set(type, version); + } + } + + // Parse supported HBCI versions + const supportedHbciVersions = bpd?.supportedVersions; + + // Parse pain formats + const painFormats = bpd?.painFormats; + + // Parse camt formats + const supportedCamtFormats = bpd?.camtFormats; + + // Parse touchdown from return values + const touchdownRv = returnValues.find((rv) => rv.code === "3040"); + const touchdown = touchdownRv?.parameters?.[0]; + + // Parse TAN challenge from dedicated segment or from 0030 return-value parameters + let tanChallenge: TanChallenge | undefined; + if (tanRequired) { + const challengeSegment = findSegment(msgBody, "TanChallenge") || findSegment(msgBody, "TANChallenge"); + if (challengeSegment) { + tanChallenge = parseTanChallenge(getXmlValue(challengeSegment, "SegBody"), tanMethodsList); + } + // Fall back to extracting from the 0030 return value parameters + if (!tanChallenge) { + const tanRv = returnValues.find((rv) => rv.code === RETURN_CODE_TAN_REQUIRED); + const ref = tanRv?.parameters?.[0]; + if (ref) { + tanChallenge = { + transactionReference: ref, + challengeText: tanRv?.parameters?.[1] || tanRv?.message, + }; + } + } + } + + return { + dialogId, + msgNo, + success, + returnValues, + systemId, + bpd, + upd, + tanMethods: tanMethodsList, + accounts, + balance: account && balanceSegBody ? parseBalance(balanceSegBody, account) : undefined, + camtData, + supportedHbciVersions, + segmentVersions, + painFormats, + supportedCamtFormats, + touchdown, + rawXml: fintsMsg, + tanRequired, + tanChallenge, + }; +} + +/** + * Quick check if an XML response string appears to be a FinTS 4.1 XML message. + */ +export function isFinTS4Response(data: string): boolean { + return data.trimStart().startsWith("