diff --git a/packages/fints/src/__tests__/test-client.ts b/packages/fints/src/__tests__/test-client.ts index 021bcc5..c81c1ca 100644 --- a/packages/fints/src/__tests__/test-client.ts +++ b/packages/fints/src/__tests__/test-client.ts @@ -66,4 +66,100 @@ describe("Client", () => { expect(resumedDialog.dialogId).toBe("9999"); expect(resumedDialog.msgNo).toBe(3); }); + + test("capabilities returns bank features derived from the sync response", async () => { + // First call is the sync request; second call is the end request. + const syncResponse = { + success: true, + returnValues: () => new Map(), + dialogId: "sync-dialog", + systemId: "sys-1", + segmentMaxVersion: (cls: any) => { + const versionMap: Record = { + HISALS: 5, + HIKAZS: 6, + HICDBS: 1, + HIDSES: 0, + HICCSS: 3, + HIWPDS: 6, + HITANS: 6, + }; + return versionMap[cls.name] ?? 0; + }, + supportedTanMethods: [] as any[], + painFormats: [] as string[], + findSegment: (cls: any) => { + if (cls.name === "HIKAZS") return { minSignatures: 1 }; + if (cls.name === "HISALS") return { minSignatures: 0 }; + return undefined; + }, + findSegments: () => [] as any[], + }; + const endResponse = { + success: true, + returnValues: () => new Map(), + dialogId: "0", + }; + + let callCount = 0; + const connection = { + send: jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve(callCount === 1 ? syncResponse : endResponse); + }), + }; + + const client = new TestClient(baseConfig, connection as any); + const caps = await client.capabilities(); + + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); // HISALS version 5 + expect(caps.supportsTransactions).toBe(true); // HIKAZS version 6 + expect(caps.supportsHoldings).toBe(true); // HIWPDS version 6 + expect(caps.supportsStandingOrders).toBe(true); // HICDBS version 1 + expect(caps.supportsCreditTransfer).toBe(true); // HICCSS version 3 + expect(caps.supportsDirectDebit).toBe(false); // HIDSES version 0 + expect(caps.requiresTanForTransactions).toBe(true); // minSignatures 1 + expect(caps.requiresTanForBalance).toBe(false); // minSignatures 0 + }); + + test("capabilities returns all false when bank advertises no optional features", async () => { + const syncResponse = { + success: true, + returnValues: () => new Map(), + dialogId: "sync-dialog", + systemId: "sys-1", + segmentMaxVersion: (_cls: any) => 0, + supportedTanMethods: [] as any[], + painFormats: [] as string[], + findSegment: (_cls: any): any => undefined, + findSegments: () => [] as any[], + }; + const endResponse = { + success: true, + returnValues: () => new Map(), + dialogId: "0", + }; + + let callCount = 0; + const connection = { + send: jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve(callCount === 1 ? syncResponse : endResponse); + }), + }; + + const client = new TestClient(baseConfig, connection as any); + const caps = await client.capabilities(); + + expect(caps.supportsAccounts).toBe(true); // always true + expect(caps.supportsBalance).toBe(false); + expect(caps.supportsTransactions).toBe(false); + expect(caps.supportsHoldings).toBe(false); + expect(caps.supportsStandingOrders).toBe(false); + expect(caps.supportsCreditTransfer).toBe(false); + expect(caps.supportsDirectDebit).toBe(false); + expect(caps.requiresTanForTransactions).toBe(false); + expect(caps.requiresTanForBalance).toBe(false); + }); }); diff --git a/packages/fints/src/__tests__/test-dialog.ts b/packages/fints/src/__tests__/test-dialog.ts index 0dd2c69..ab6d83e 100644 --- a/packages/fints/src/__tests__/test-dialog.ts +++ b/packages/fints/src/__tests__/test-dialog.ts @@ -52,4 +52,68 @@ describe("Dialog", () => { expect(dialog.dialogId).toBe("4711"); } }); + + test("capabilities getter reflects fields set during sync", () => { + const dialog = new Dialog(baseConfig, {} as any); + + // Simulate the state after a sync response has been processed. + dialog.supportsBalance = true; + dialog.supportsTransactions = true; + dialog.hiwpdsVersion = 6; + dialog.supportsStandingOrders = true; + dialog.supportsCreditTransfer = true; + dialog.supportsDirectDebit = false; + dialog.hikazsMinSignatures = 1; + dialog.hisalsMinSignatures = 0; + + const caps = dialog.capabilities; + + expect(caps.supportsAccounts).toBe(true); + expect(caps.supportsBalance).toBe(true); + expect(caps.supportsTransactions).toBe(true); + expect(caps.supportsHoldings).toBe(true); + expect(caps.supportsStandingOrders).toBe(true); + expect(caps.supportsCreditTransfer).toBe(true); + expect(caps.supportsDirectDebit).toBe(false); + expect(caps.requiresTanForTransactions).toBe(true); + expect(caps.requiresTanForBalance).toBe(false); + }); + + test("capabilities getter returns false for unsupported features", () => { + const dialog = new Dialog(baseConfig, {} as any); + + // Simulate a bank that advertises no optional features. + dialog.supportsBalance = false; + dialog.supportsTransactions = false; + dialog.hiwpdsVersion = 0; + dialog.supportsStandingOrders = false; + dialog.supportsCreditTransfer = false; + dialog.supportsDirectDebit = false; + dialog.hikazsMinSignatures = 0; + dialog.hisalsMinSignatures = 0; + + const caps = dialog.capabilities; + + expect(caps.supportsAccounts).toBe(true); // always true + expect(caps.supportsBalance).toBe(false); + expect(caps.supportsTransactions).toBe(false); + expect(caps.supportsHoldings).toBe(false); + expect(caps.supportsStandingOrders).toBe(false); + expect(caps.supportsCreditTransfer).toBe(false); + expect(caps.supportsDirectDebit).toBe(false); + expect(caps.requiresTanForTransactions).toBe(false); + expect(caps.requiresTanForBalance).toBe(false); + }); + + test("capabilities getter returns false before sync() is called", () => { + const dialog = new Dialog(baseConfig, {} as any); + // No sync() has run - all support flags should be false + const caps = dialog.capabilities; + expect(caps.supportsBalance).toBe(false); + expect(caps.supportsTransactions).toBe(false); + 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/client.ts b/packages/fints/src/client.ts index 3383c50..87acaa6 100644 --- a/packages/fints/src/client.ts +++ b/packages/fints/src/client.ts @@ -31,6 +31,7 @@ import { DirectDebitSubmission, CreditTransferRequest, CreditTransferSubmission, + BankCapabilities, } from "./types"; import { read } from "mt940-js"; import { parse86Structured } from "./mt940-86-structured"; @@ -57,6 +58,21 @@ export abstract class Client { */ protected abstract createRequest(dialog: Dialog, segments: Segment[], tan?: string): Request; + /** + * Retrieve the capabilities of the bank by performing a synchronisation request. + * + * The capabilities are derived from the parameter segments the bank advertises during + * the initial sync dialog (e.g. HIKAZS, HISALS, HIWPDS, HICCSS, HIDSES, …). + * No additional authentication beyond the configured credentials is required. + * + * @return An object describing what operations this bank supports. + */ + 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. * diff --git a/packages/fints/src/dialog.ts b/packages/fints/src/dialog.ts index b103aeb..b05a34d 100644 --- a/packages/fints/src/dialog.ts +++ b/packages/fints/src/dialog.ts @@ -1,4 +1,4 @@ -import { Connection } from "./types"; +import { Connection, BankCapabilities } from "./types"; import { HKIDN, HKVVB, @@ -103,6 +103,41 @@ export class Dialog extends DialogConfig { * Stores the maximum supported version parsed during synchronization. */ public hiwpdsVersion = 0; + /** + * Whether the bank supports querying account balances (HKSAL). + * Set to `true` during synchronization if the bank returns a HISALS parameter segment. + */ + public supportsBalance = false; + /** + * Whether the bank supports fetching bank statements / transaction history (HKKAZ). + * Set to `true` during synchronization if the bank returns a HIKAZS parameter segment. + */ + public supportsTransactions = false; + /** + * Whether the bank supports fetching standing orders (HKCDB). + * Set to `true` during synchronization if the bank returns a HICDBS parameter segment. + */ + public supportsStandingOrders = false; + /** + * Whether the bank supports SEPA credit transfers (HKCCS). + * Set to `true` during synchronization if the bank returns a HICCSS parameter segment. + */ + public supportsCreditTransfer = false; + /** + * Whether the bank supports SEPA direct debits (HKDSE). + * Set to `true` during synchronization if the bank returns a HIDSES parameter segment. + */ + public supportsDirectDebit = false; + /** + * Minimum number of signatures required to fetch bank statements (from HIKAZS). + * A value greater than `0` means a TAN is required. + */ + public hikazsMinSignatures = 0; + /** + * Minimum number of signatures required to query account balances (from HISALS). + * A value greater than `0` means a TAN is required. + */ + public hisalsMinSignatures = 0; /** * A list of supported SEPA pain-formats as configured by the server. */ @@ -148,17 +183,29 @@ export class Dialog extends DialogConfig { const response = await this.send(new Request({ blz, name, pin, systemId, dialogId, msgNo, segments })); this.systemId = escapeFinTS(response.systemId); this.dialogId = response.dialogId; - this.hisalsVersion = response.segmentMaxVersion(HISALS); - this.hikazsVersion = response.segmentMaxVersion(HIKAZS); - this.hicdbVersion = response.segmentMaxVersion(HICDBS); + const hisalsVer = response.segmentMaxVersion(HISALS); + this.supportsBalance = hisalsVer > 0; + if (hisalsVer > 0) this.hisalsVersion = hisalsVer; + const hikazsVer = response.segmentMaxVersion(HIKAZS); + this.supportsTransactions = hikazsVer > 0; + if (hikazsVer > 0) this.hikazsVersion = hikazsVer; + const hicdbVer = response.segmentMaxVersion(HICDBS); + this.supportsStandingOrders = hicdbVer > 0; + if (hicdbVer > 0) this.hicdbVersion = hicdbVer; const hkdseVersion = response.segmentMaxVersion(HIDSES); this.hkdseVersion = hkdseVersion > 0 ? hkdseVersion : 1; + this.supportsDirectDebit = hkdseVersion > 0; const hkccsVersion = response.segmentMaxVersion(HICCSS); this.hkccsVersion = hkccsVersion > 0 ? hkccsVersion : 1; + this.supportsCreditTransfer = hkccsVersion > 0; this.hiwpdsVersion = response.segmentMaxVersion(HIWPDS); this.hktanVersion = response.segmentMaxVersion(HITANS); this.tanMethods = response.supportedTanMethods; this.painFormats = response.painFormats; + const hikazs = response.findSegment(HIKAZS); + this.hikazsMinSignatures = hikazs?.minSignatures ?? 0; + const hisals = response.findSegment(HISALS); + this.hisalsMinSignatures = hisals?.minSignatures ?? 0; const hiupd = response.findSegments(HIUPD); this.hiupd = hiupd; await this.end(); @@ -326,4 +373,25 @@ export class Dialog extends DialogConfig { this.decoupledTanManager = undefined; } } + + /** + * Returns the capabilities of the bank based on the parameter segments + * received during the last synchronisation. + * + * Call this only after `sync()` has been invoked so that all version + * fields have been populated from the server response. + */ + public get capabilities(): BankCapabilities { + return { + supportsAccounts: true, + supportsBalance: this.supportsBalance, + supportsTransactions: this.supportsTransactions, + supportsHoldings: this.hiwpdsVersion > 0, + supportsStandingOrders: this.supportsStandingOrders, + supportsCreditTransfer: this.supportsCreditTransfer, + supportsDirectDebit: this.supportsDirectDebit, + requiresTanForTransactions: this.hikazsMinSignatures > 0, + requiresTanForBalance: this.hisalsMinSignatures > 0, + }; + } } diff --git a/packages/fints/src/types.ts b/packages/fints/src/types.ts index 5b253fc..01a0037 100644 --- a/packages/fints/src/types.ts +++ b/packages/fints/src/types.ts @@ -394,6 +394,60 @@ export interface Holding { acquisitionPrice?: number; } +/** + * Describes the capabilities of a bank as determined during the initial synchronisation dialog. + * + * Each flag reflects whether the bank advertises support for the corresponding FinTS business + * transaction via its parameter segments (e.g. HIKAZS, HISALS, HIWPDS, …). + */ +export interface BankCapabilities { + /** + * Whether the bank supports retrieving the list of SEPA accounts (HKSPA). + * This is always `true` for any conforming FinTS server. + */ + supportsAccounts: boolean; + /** + * Whether the bank supports querying account balances (HKSAL). + * Derived from the presence of a HISALS parameter segment in the sync response. + */ + supportsBalance: boolean; + /** + * Whether the bank supports fetching bank statements / transaction history (HKKAZ). + * Derived from the presence of a HIKAZS parameter segment in the sync response. + */ + supportsTransactions: boolean; + /** + * Whether the bank supports fetching securities and holdings for a depot account (HKWPD). + * Derived from the presence of a HIWPDS parameter segment in the sync response. + */ + supportsHoldings: boolean; + /** + * Whether the bank supports fetching standing orders (HKCDB). + * Derived from the presence of a HICDBS parameter segment in the sync response. + */ + supportsStandingOrders: boolean; + /** + * Whether the bank supports initiating SEPA credit transfers (HKCCS). + * Derived from the presence of a HICCSS parameter segment in the sync response. + */ + supportsCreditTransfer: boolean; + /** + * Whether the bank supports submitting SEPA direct debits (HKDSE). + * Derived from the presence of a HIDSES parameter segment in the sync response. + */ + supportsDirectDebit: boolean; + /** + * Whether a TAN is required to fetch bank statements. + * Derived from the `minSignatures` field of the HIKAZS parameter segment (`minSignatures > 0`). + */ + requiresTanForTransactions: boolean; + /** + * Whether a TAN is required to query account balances. + * Derived from the `minSignatures` field of the HISALS parameter segment (`minSignatures > 0`). + */ + requiresTanForBalance: boolean; +} + /** * A connection used in the client to contact the server. */