From 4273dabd4eb3c76ec96c0b7370dae2e85e89925a Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Thu, 11 Dec 2025 16:57:48 +0100 Subject: [PATCH 1/8] Add SEPA account retireval support with HKSPA, HISPA and HISPAS segments --- src/dataGroups/SepaAccountParameters.ts | 33 ++++++++ src/interactions/sepaAccountInteraction.ts | 51 +++++++++++++ src/segments/HISPA.ts | 20 +++++ src/segments/HISPAS.ts | 23 ++++++ src/segments/HKSPA.ts | 28 +++++++ src/segments/registry.ts | 88 +++++++++++----------- 6 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 src/dataGroups/SepaAccountParameters.ts create mode 100644 src/interactions/sepaAccountInteraction.ts create mode 100644 src/segments/HISPA.ts create mode 100644 src/segments/HISPAS.ts create mode 100644 src/segments/HKSPA.ts diff --git a/src/dataGroups/SepaAccountParameters.ts b/src/dataGroups/SepaAccountParameters.ts new file mode 100644 index 0000000..cef170f --- /dev/null +++ b/src/dataGroups/SepaAccountParameters.ts @@ -0,0 +1,33 @@ +import { YesNo } from '../dataElements/YesNo.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { DataGroup } from './DataGroup.js'; + +export type SepaAccountParameters = { + individualAccountRetrievalAllowed: boolean; + nationalAccountAllowed: boolean; + structuredPurposeAllowed: boolean; + maxEntriesAllowed?: boolean; // version 2+ + reservedPurposePositions?: number; // version 3+ + supportedSepaFormats?: string[]; // optional, up to 99 entries +}; + +export class SepaAccountParametersGroup extends DataGroup { + constructor(name: string, minCount = 1, maxCount = 1, minVersion?: number, maxVersion?: number) { + super( + name, + [ + new YesNo('individualAccountRetrievalAllowed', 1, 1), + new YesNo('nationalAccountAllowed', 1, 1), + new YesNo('structuredPurposeAllowed', 1, 1), + new YesNo('maxEntriesAllowed', 1, 1, 2), // version 2+ + new Numeric('reservedPurposePositions', 1, 1, 2, 3), // version 3+ + new AlphaNumeric('supportedSepaFormats', 0, 99, 256), // optional, up to 99 entries + ], + minCount, + maxCount, + minVersion, + maxVersion + ); + } +} diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts new file mode 100644 index 0000000..3a5e03a --- /dev/null +++ b/src/interactions/sepaAccountInteraction.ts @@ -0,0 +1,51 @@ +import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './customerInteraction.js'; +import { Message } from '../message.js'; +import { Segment } from '../segment.js'; +import { HISPA, HISPASegment } from '../segments/HISPA.js'; +import { HKSPA, HKSPASegment } from '../segments/HKSPA.js'; +import { InternationalAccount } from '../dataGroups/InternationalAccount.js'; +import { FinTSConfig } from '../config.js'; + +export interface SepaAccountResponse extends ClientResponse { + sepaAccounts?: InternationalAccount[]; +} + +export class SepaAccountInteraction extends CustomerOrderInteraction { + constructor( + public accounts?: string[], // optional specific account numbers + public maxEntries?: number + ) { + super(HKSPA.Id, HISPA.Id); + } + + createSegments(init: FinTSConfig): Segment[] { + if (!init.isTransactionSupported(this.segId)) { + throw Error(`Business transaction '${this.segId}' is not supported by this bank`); + } + + const version = init.getMaxSupportedTransactionVersion(HKSPA.Id); + + if (!version) { + throw Error(`There is no supported version for business transaction '${HKSPA.Id}'`); + } + + const accounts = this.accounts?.map((accountNumber) => { + return init.getBankAccount(accountNumber); + }); + + const hkspa: HKSPASegment = { + header: { segId: HKSPA.Id, segNr: 0, version: version }, + accounts: accounts, + maxEntries: this.maxEntries, + }; + + return [hkspa]; + } + + handleResponse(response: Message, clientResponse: SepaAccountResponse) { + const hispa = response.findSegment(HISPA.Id); + if (hispa) { + clientResponse.sepaAccounts = hispa.sepaAccounts || []; + } + } +} diff --git a/src/segments/HISPA.ts b/src/segments/HISPA.ts new file mode 100644 index 0000000..f609f90 --- /dev/null +++ b/src/segments/HISPA.ts @@ -0,0 +1,20 @@ +import { InternationalAccount, InternationalAccountGroup } from '../dataGroups/InternationalAccount.js'; +import { Segment } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; + +export type HISPASegment = Segment & { + sepaAccounts: InternationalAccount[]; +}; + +/** + * SEPA account connection response - returns IBAN/BIC information + * Version 3 - returns SEPA account data in ktz format + */ +export class HISPA extends SegmentDefinition { + static Id = 'HISPA'; + version = 3; + constructor() { + super(HISPA.Id); + } + elements = [new InternationalAccountGroup('sepaAccounts', 0, 999)]; +} diff --git a/src/segments/HISPAS.ts b/src/segments/HISPAS.ts new file mode 100644 index 0000000..d4d92e2 --- /dev/null +++ b/src/segments/HISPAS.ts @@ -0,0 +1,23 @@ +import { BusinessTransactionParameter, BusinessTransactionParameterSegment } from './businessTransactionParameter.js'; +import { SepaAccountParameters, SepaAccountParametersGroup } from '../dataGroups/SepaAccountParameters.js'; + +export type HISPASSegment = BusinessTransactionParameterSegment; + +export type HISPASParameter = SepaAccountParameters; + +/** + * Parameters for HKSPA business transaction - SEPA account connection request + * Version 3 supports all parameters including reserved purpose positions + */ +export class HISPAS extends BusinessTransactionParameter { + static Id = 'HISPAS'; + version = 3; + + constructor() { + super( + HISPAS.Id, + [new SepaAccountParametersGroup('sepaAccountParams', 1, 1)], + 1 // secClassMinVersion + ); + } +} diff --git a/src/segments/HKSPA.ts b/src/segments/HKSPA.ts new file mode 100644 index 0000000..41f28e6 --- /dev/null +++ b/src/segments/HKSPA.ts @@ -0,0 +1,28 @@ +import { Numeric } from '../dataElements/Numeric.js'; +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { Account, AccountGroup } from '../dataGroups/Account.js'; +import { SegmentWithContinuationMark } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; + +export type HKSPASegment = SegmentWithContinuationMark & { + accounts?: Account[]; // optional, up to 999 accounts + maxEntries?: number; // version 2+, conditional +}; + +/** + * Request SEPA account connections (IBAN/BIC information) + * Version 3 - supports account specification, max entries and continuation + */ +export class HKSPA extends SegmentDefinition { + static Id = 'HKSPA'; + static Version = 3; + constructor() { + super(HKSPA.Id); + } + version = HKSPA.Version; + elements = [ + new AccountGroup('accounts', 0, 999), + new Numeric('maxEntries', 0, 1, 4, 2), + new AlphaNumeric('continuationMark', 0, 1, 35, 2), + ]; +} diff --git a/src/segments/registry.ts b/src/segments/registry.ts index 047b08e..80194fc 100644 --- a/src/segments/registry.ts +++ b/src/segments/registry.ts @@ -30,57 +30,61 @@ import { HIKAZS } from './HIKAZS.js'; import { HITAB } from './HITAB.js'; import { HKWPD } from './HKWPD.js'; import { HIWPD } from './HIWPD.js'; -import { DIKKU } from "./DIKKU.js"; -import { DKKKU } from "./DKKKU.js"; +import { HKSPA } from './HKSPA.js'; +import { HISPA } from './HISPA.js'; +import { HISPAS } from './HISPAS.js'; +import { DIKKU } from './DIKKU.js'; +import { DKKKU } from './DKKKU.js'; import { UNKNOW } from '../unknownSegment.js'; import { PARTED } from '../partedSegment.js'; const registry = new Map(); export function registerSegments() { - registerSegmentDefinition(new HNHBK()); - registerSegmentDefinition(new HNHBS()); - registerSegmentDefinition(new HNVSK()); - registerSegmentDefinition(new HNVSD()); - registerSegmentDefinition(new HNSHK()); - registerSegmentDefinition(new HNSHA()); - registerSegmentDefinition(new HKIDN()); - registerSegmentDefinition(new HKVVB()); - registerSegmentDefinition(new HKSYN()); - registerSegmentDefinition(new HKTAN()); - registerSegmentDefinition(new HKTAB()); - registerSegmentDefinition(new HIRMG()); - registerSegmentDefinition(new HIRMS()); - registerSegmentDefinition(new HIBPA()); - registerSegmentDefinition(new HIKOM()); - registerSegmentDefinition(new HIKIM()); - registerSegmentDefinition(new HISYN()); - registerSegmentDefinition(new HITAB()); - registerSegmentDefinition(new HIPINS()); - registerSegmentDefinition(new HITAN()); - registerSegmentDefinition(new HITANS()); - registerSegmentDefinition(new HIUPA()); - registerSegmentDefinition(new HIUPD()); - registerSegmentDefinition(new HKEND()); - registerSegmentDefinition(new HKSAL()); - registerSegmentDefinition(new HISAL()); - registerSegmentDefinition(new HKKAZ()); - registerSegmentDefinition(new DKKKU()); - registerSegmentDefinition(new DIKKU()); - registerSegmentDefinition(new HIKAZ()); - registerSegmentDefinition(new HIKAZS()); - registerSegmentDefinition(new HKWPD()); - registerSegmentDefinition(new HIWPD()); - registerSegmentDefinition(new UNKNOW()); - registerSegmentDefinition(new PARTED()); + registerSegmentDefinition(new HNHBK()); + registerSegmentDefinition(new HNHBS()); + registerSegmentDefinition(new HNVSK()); + registerSegmentDefinition(new HNVSD()); + registerSegmentDefinition(new HNSHK()); + registerSegmentDefinition(new HNSHA()); + registerSegmentDefinition(new HKIDN()); + registerSegmentDefinition(new HKVVB()); + registerSegmentDefinition(new HKSYN()); + registerSegmentDefinition(new HKTAN()); + registerSegmentDefinition(new HKTAB()); + registerSegmentDefinition(new HIRMG()); + registerSegmentDefinition(new HIRMS()); + registerSegmentDefinition(new HIBPA()); + registerSegmentDefinition(new HIKOM()); + registerSegmentDefinition(new HIKIM()); + registerSegmentDefinition(new HISYN()); + registerSegmentDefinition(new HITAB()); + registerSegmentDefinition(new HIPINS()); + registerSegmentDefinition(new HITAN()); + registerSegmentDefinition(new HITANS()); + registerSegmentDefinition(new HIUPA()); + registerSegmentDefinition(new HIUPD()); + registerSegmentDefinition(new HKEND()); + registerSegmentDefinition(new HKSAL()); + registerSegmentDefinition(new HISAL()); + registerSegmentDefinition(new HKKAZ()); + registerSegmentDefinition(new DKKKU()); + registerSegmentDefinition(new DIKKU()); + registerSegmentDefinition(new HIKAZ()); + registerSegmentDefinition(new HIKAZS()); + registerSegmentDefinition(new HKWPD()); + registerSegmentDefinition(new HIWPD()); + registerSegmentDefinition(new HKSPA()); + registerSegmentDefinition(new HISPA()); + registerSegmentDefinition(new HISPAS()); + registerSegmentDefinition(new UNKNOW()); + registerSegmentDefinition(new PARTED()); } -export function getSegmentDefinition( - id: string -): SegmentDefinition | undefined { - return registry.get(id); +export function getSegmentDefinition(id: string): SegmentDefinition | undefined { + return registry.get(id); } function registerSegmentDefinition(definition: SegmentDefinition) { - registry.set(definition.id, definition); + registry.set(definition.id, definition); } From f440d959b58937214574ed23e20b8c8c38079d6a Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Fri, 12 Dec 2025 12:22:44 +0100 Subject: [PATCH 2/8] Added SEPA account datagroup and some cleanup --- src/dataGroups/SepaAccount.ts | 34 +++++++++++++++++++++++ src/interactions/customerInteraction.ts | 6 ++-- src/interactions/initDialogInteraction.ts | 1 - src/segments/HISPA.ts | 6 ++-- 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 src/dataGroups/SepaAccount.ts diff --git a/src/dataGroups/SepaAccount.ts b/src/dataGroups/SepaAccount.ts new file mode 100644 index 0000000..9d55b12 --- /dev/null +++ b/src/dataGroups/SepaAccount.ts @@ -0,0 +1,34 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { DataGroup } from './DataGroup.js'; +import { Identification } from '../dataElements/Identification.js'; +import { BankIdentification } from './BankIdentification.js'; +import { Bank } from './Account.js'; +import { YesNo } from '../dataElements/YesNo.js'; + +export type SepaAccount = { + iban?: string; + bic?: string; + accountNumber?: string; + subAccountId?: string; + bank?: Bank; +}; + +export class SepaAccountGroup extends DataGroup { + constructor(name: string, minCount = 0, maxCount = 1, minVersion?: number, maxVersion?: number) { + super( + name, + [ + new YesNo('isSepaAccount', 1, 1), + new AlphaNumeric('iban', 0, 1, 34), + new AlphaNumeric('bic', 0, 1, 11), + new Identification('accountNumber', 1, 1), + new Identification('subAccountId', 0, 1), + new BankIdentification('bank', 1, 1), + ], + minCount, + maxCount, + minVersion, + maxVersion + ); + } +} diff --git a/src/interactions/customerInteraction.ts b/src/interactions/customerInteraction.ts index 57eede6..0a65117 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -33,8 +33,8 @@ export abstract class CustomerInteraction { constructor(public segId: string) {} - getSegments(init: FinTSConfig): Segment[] { - return this.createSegments(init); + getSegments(config: FinTSConfig): Segment[] { + return this.createSegments(config); } getClientResponse(message: Message): TResponse { @@ -47,7 +47,7 @@ export abstract class CustomerInteraction { return clientResponse as TResponse; } - protected abstract createSegments(init: FinTSConfig): Segment[]; + protected abstract createSegments(config: FinTSConfig): Segment[]; protected abstract handleResponse(response: Message, clientResponse: ClientResponse): void; private handleBaseResponse(response: Message): ClientResponse { diff --git a/src/interactions/initDialogInteraction.ts b/src/interactions/initDialogInteraction.ts index c6c1081..b952fe9 100644 --- a/src/interactions/initDialogInteraction.ts +++ b/src/interactions/initDialogInteraction.ts @@ -163,7 +163,6 @@ export class InitDialogInteraction extends CustomerInteraction { } const tanMethodMessaqe = bankAnswers.find((answer) => answer.code === 3920); - let availableTanMethodIds: number[] = []; if (tanMethodMessaqe && this.config.bankingInformation.bpd) { this.config.bankingInformation.bpd.availableTanMethodIds = diff --git a/src/segments/HISPA.ts b/src/segments/HISPA.ts index f609f90..7e4d33b 100644 --- a/src/segments/HISPA.ts +++ b/src/segments/HISPA.ts @@ -1,9 +1,9 @@ -import { InternationalAccount, InternationalAccountGroup } from '../dataGroups/InternationalAccount.js'; +import { SepaAccount, SepaAccountGroup } from '../dataGroups/SepaAccount.js'; import { Segment } from '../segment.js'; import { SegmentDefinition } from '../segmentDefinition.js'; export type HISPASegment = Segment & { - sepaAccounts: InternationalAccount[]; + sepaAccounts: SepaAccount[]; }; /** @@ -16,5 +16,5 @@ export class HISPA extends SegmentDefinition { constructor() { super(HISPA.Id); } - elements = [new InternationalAccountGroup('sepaAccounts', 0, 999)]; + elements = [new SepaAccountGroup('sepaAccounts', 0, 999)]; } From e81a35d534157ebcdd274225c5fd0b6b15291de1 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Fri, 12 Dec 2025 14:19:56 +0100 Subject: [PATCH 3/8] Refactor FinTSClient and Dialog classes, dialog will maintain interactions and client will hold the last dialog --- src/client.ts | 59 ++++++++++++++++++--------------------------------- src/dialog.ts | 34 +++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/client.ts b/src/client.ts index 154f17b..c982a6e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,7 +12,8 @@ import { HKKAZ } from './segments/HKKAZ.js'; import { HKWPD } from './segments/HKWPD.js'; import { DKKKU } from './segments/DKKKU.js'; import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; -import {CreditCardStatementInteraction} from "./interactions/creditcardStatementInteraction.js"; +import { CreditCardStatementInteraction } from './interactions/creditcardStatementInteraction.js'; +import { SepaAccountInteraction, SepaAccountResponse } from './interactions/sepaAccountInteraction.js'; export interface SynchronizeResponse extends InitResponse {} @@ -20,7 +21,7 @@ export interface SynchronizeResponse extends InitResponse {} * A client to communicate with a bank over the FinTS protocol */ export class FinTSClient { - private openCustomerInteractions = new Map(); + private lastDialog: Dialog | undefined; /** * Creates a new FinTS client @@ -59,16 +60,16 @@ export class FinTSClient { * @returns the synchronization response */ async synchronize(): Promise { - const dialog = new Dialog(this.config); - - const syncResponse = await this.initDialog(dialog, true); + const syncResponse = await this.initDialog(true); if (!syncResponse.success || syncResponse.requiresTan) { return syncResponse; } if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { - const tanMediaResponse = await dialog.startCustomerOrderInteraction(new TanMediaInteraction()); + const tanMediaResponse = await this.lastDialog!.startCustomerOrderInteraction( + new TanMediaInteraction() + ); let tanMethod = this.config.selectedTanMethod; if (tanMethod) { @@ -78,7 +79,7 @@ export class FinTSClient { syncResponse.bankAnswers.push(...tanMediaResponse.bankAnswers); } - await dialog.end(); + await this.lastDialog!.end(); return syncResponse; } @@ -195,7 +196,6 @@ export class FinTSClient { return this.continueCustomerInteractionWithTan(tanReference, tan); } - /** * Checks if the bank supports fetching credit card statements in general or for the given account number * @param accountNumber when the account number is provided, checks if the account supports fetching of statements @@ -232,19 +232,16 @@ export class FinTSClient { private async startCustomerOrderInteraction( interaction: CustomerOrderInteraction ): Promise { - const dialog = new Dialog(this.config); - const syncResponse = await this.initDialog(dialog, false, interaction); + const syncResponse = await this.initDialog(false, interaction); if (!syncResponse.success || syncResponse.requiresTan) { return syncResponse as TClientResponse; } - const clientResponse = await dialog.startCustomerOrderInteraction(interaction); + const clientResponse = await this.lastDialog!.startCustomerOrderInteraction(interaction); - if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, interaction); - } else { - await dialog.end(); + if (!clientResponse.requiresTan) { + await this.lastDialog!.end(); } return clientResponse; @@ -254,56 +251,42 @@ export class FinTSClient { tanReference: string, tan?: string ): Promise { - const interaction = this.openCustomerInteractions.get(tanReference); - - if (!interaction) { - throw new Error('No open customer interaction found for TAN reference: ' + tanReference); + if (!this.lastDialog) { + throw new Error('no customer interaction was started which can continue'); } - const dialog = interaction.dialog!; - let responseMessage = await dialog.sendTanMessage(interaction.segId, tanReference, tan); - let clientResponse = interaction.getClientResponse(responseMessage) as TClientResponse; - - this.openCustomerInteractions.delete(tanReference); + let clientResponse = await this.lastDialog.continueCustomerOrderInteraction(tanReference, tan); if (!clientResponse.success) { - await dialog.end(); + await this.lastDialog.end(); return clientResponse; } if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, interaction); return clientResponse; } - const initDialogInteraction = interaction as InitDialogInteraction; + const initDialogInteraction = this.lastDialog.lastInteraction as InitDialogInteraction; if (initDialogInteraction.followUpInteraction) { - clientResponse = await dialog.startCustomerOrderInteraction( + clientResponse = await this.lastDialog.startCustomerOrderInteraction( initDialogInteraction.followUpInteraction ); if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, initDialogInteraction.followUpInteraction); return clientResponse; } } - await dialog.end(); + await this.lastDialog.end(); return clientResponse; } private async initDialog( - dialog: Dialog, syncSystemId = false, followUpInteraction?: CustomerOrderInteraction ): Promise { + this.lastDialog = new Dialog(this.config); const interaction = new InitDialogInteraction(this.config, syncSystemId, followUpInteraction); - const initResponse = await dialog.initialize(interaction); - - if (initResponse.requiresTan) { - this.openCustomerInteractions.set(initResponse.tanReference!, interaction); - } - - return initResponse; + return await this.lastDialog.initialize(interaction); } } diff --git a/src/dialog.ts b/src/dialog.ts index 0364aef..f11c374 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -9,12 +9,13 @@ import { HKTAN, HKTANSegment } from './segments/HKTAN.js'; import { HNHBK, HNHBKSegment } from './segments/HNHBK.js'; import { decode } from './segment.js'; import { PARTED, PartedSegment } from './partedSegment.js'; -import { ClientResponse, CustomerOrderInteraction } from './interactions/customerInteraction.js'; +import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './interactions/customerInteraction.js'; import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; export class Dialog { dialogId: string = '0'; lastMessageNumber = 0; + interactions: CustomerInteraction[] = []; isInitialized = false; hasEnded = false; httpClient: HttpClient; @@ -27,6 +28,13 @@ export class Dialog { this.httpClient = this.getHttpClient(); } + get lastInteraction(): CustomerInteraction | undefined { + if (this.interactions.length === 0) { + return undefined; + } + return this.interactions[this.interactions.length - 1]; + } + async initialize(interaction: InitDialogInteraction): Promise { if (this.isInitialized) { throw new Error('dialog has already been initialized'); @@ -41,6 +49,7 @@ export class Dialog { } interaction.dialog = this; + this.interactions.push(interaction); this.lastMessageNumber++; const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); @@ -144,6 +153,7 @@ export class Dialog { } interaction.dialog = this; + this.interactions.push(interaction); this.lastMessageNumber++; const message = new CustomerOrderMessage( interaction.segId, @@ -221,9 +231,12 @@ export class Dialog { return interaction.getClientResponse(responseMessage); } - async sendTanMessage(refSegId: string, tanOrderReference: string, tan?: string): Promise { - if (!refSegId || !tanOrderReference) { - throw Error('refSegId and tanOrderReference must be provided to send a TAN message'); + async continueCustomerOrderInteraction( + tanOrderReference: string, + tan?: string + ): Promise { + if (!tanOrderReference) { + throw Error('tanOrderReference must be provided to continue a customer order with a TAN'); } if (!this.config.selectedTanMethod?.isDecoupled && !tan) { @@ -235,9 +248,14 @@ export class Dialog { } if (this.hasEnded) { - throw Error('cannot send a TAN message when dialog has alreay ended'); + throw Error('cannot continue a customer order when dialog has already ended'); } + if (this.interactions.length === 0) { + throw new Error('No running customer interaction found to continue'); + } + + const interaction = this.interactions[this.interactions.length - 1]; this.lastMessageNumber++; const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); @@ -257,7 +275,7 @@ export class Dialog { const hktan: HKTANSegment = { header: { segId: HKTAN.Id, segNr: 0, version: this.config.selectedTanMethod!.version }, tanProcess: this.config.selectedTanMethod?.isDecoupled ? TanProcess.Status : TanProcess.Process2, - segId: refSegId, + segId: interaction.segId, orderRef: tanOrderReference, nextTan: false, tanMedia: @@ -270,10 +288,8 @@ export class Dialog { } const responseMessage = await this.httpClient.sendMessage(message); - this.checkEnded(responseMessage); - - return responseMessage; + return interaction.getClientResponse(responseMessage) as TClientResponse; } private checkEnded(initResponse: Message) { From c8d2a71ed17ce4a86cef106455c3728884b9a496 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Fri, 12 Dec 2025 15:42:59 +0100 Subject: [PATCH 4/8] refactor BankAccount, bankInformationUpdated handled in base method --- src/bankAccount.ts | 60 +++++++++++----------- src/client.ts | 3 +- src/dataGroups/SepaAccount.ts | 8 ++- src/interactions/customerInteraction.ts | 5 ++ src/interactions/initDialogInteraction.ts | 5 +- src/interactions/sepaAccountInteraction.ts | 16 ++++-- 6 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/bankAccount.ts b/src/bankAccount.ts index bfb6458..cbc76cd 100644 --- a/src/bankAccount.ts +++ b/src/bankAccount.ts @@ -1,40 +1,40 @@ import { Account } from './dataGroups/Account.js'; +import { SepaAccount } from './dataGroups/SepaAccount.js'; import { AccountLimit, AllowedTransactions } from './segments/HIUPD.js'; export enum AccountType { - CheckingAccount = 'CheckingAccount', - SavingsAccount = 'SavingsAccount', - FixedDepositAccount = 'FixedDepositAccount', - SecuritiesAccount = 'SecuritiesAccount', - LoanMortgageAccount = 'LoanMortgageAccount', - CreditCardAccount = 'CreditCardAccount', - InvestmentCompanyFund = 'InvestmentCompanyFund', - HomeSavingsContract = 'HomeSavingsContract', - InsurancePolicy = 'InsurancePolicy', - Miscellaneous = 'Miscellaneous', + CheckingAccount = 'CheckingAccount', + SavingsAccount = 'SavingsAccount', + FixedDepositAccount = 'FixedDepositAccount', + SecuritiesAccount = 'SecuritiesAccount', + LoanMortgageAccount = 'LoanMortgageAccount', + CreditCardAccount = 'CreditCardAccount', + InvestmentCompanyFund = 'InvestmentCompanyFund', + HomeSavingsContract = 'HomeSavingsContract', + InsurancePolicy = 'InsurancePolicy', + Miscellaneous = 'Miscellaneous', } -export type BankAccount = Account & { - iban?: string; - customerId: string; - accountType: AccountType; - currency: string; - holder1: string; - holder2?: string; - product?: string; - limit?: AccountLimit; - allowedTransactions?: AllowedTransactions[]; +export type BankAccount = SepaAccount & { + customerId: string; + accountType: AccountType; + currency: string; + holder1: string; + holder2?: string; + product?: string; + limit?: AccountLimit; + allowedTransactions?: AllowedTransactions[]; }; export function finTsAccountTypeToEnum(accountType: number): AccountType { - if (accountType >= 1 && accountType <= 9) return AccountType.CheckingAccount; - if (accountType >= 10 && accountType <= 19) return AccountType.SavingsAccount; - if (accountType >= 20 && accountType <= 29) return AccountType.FixedDepositAccount; - if (accountType >= 30 && accountType <= 39) return AccountType.SecuritiesAccount; - if (accountType >= 40 && accountType <= 49) return AccountType.LoanMortgageAccount; - if (accountType >= 50 && accountType <= 59) return AccountType.CreditCardAccount; - if (accountType >= 60 && accountType <= 69) return AccountType.InvestmentCompanyFund; - if (accountType >= 70 && accountType <= 79) return AccountType.HomeSavingsContract; - if (accountType >= 80 && accountType <= 89) return AccountType.InsurancePolicy; - return AccountType.Miscellaneous; + if (accountType >= 1 && accountType <= 9) return AccountType.CheckingAccount; + if (accountType >= 10 && accountType <= 19) return AccountType.SavingsAccount; + if (accountType >= 20 && accountType <= 29) return AccountType.FixedDepositAccount; + if (accountType >= 30 && accountType <= 39) return AccountType.SecuritiesAccount; + if (accountType >= 40 && accountType <= 49) return AccountType.LoanMortgageAccount; + if (accountType >= 50 && accountType <= 59) return AccountType.CreditCardAccount; + if (accountType >= 60 && accountType <= 69) return AccountType.InvestmentCompanyFund; + if (accountType >= 70 && accountType <= 79) return AccountType.HomeSavingsContract; + if (accountType >= 80 && accountType <= 89) return AccountType.InsurancePolicy; + return AccountType.Miscellaneous; } diff --git a/src/client.ts b/src/client.ts index c982a6e..d748c8f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ import { StatementResponse, StatementInteraction } from './interactions/statemen import { AccountBalanceResponse, BalanceInteraction } from './interactions/balanceInteraction.js'; import { PortfolioResponse, PortfolioInteraction } from './interactions/portfolioInteraction.js'; import { FinTSConfig } from './config.js'; -import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './interactions/customerInteraction.js'; +import { ClientResponse, CustomerOrderInteraction } from './interactions/customerInteraction.js'; import { TanMediaInteraction, TanMediaResponse } from './interactions/tanMediaInteraction.js'; import { TanMethod } from './tanMethod.js'; import { HKSAL } from './segments/HKSAL.js'; @@ -13,7 +13,6 @@ import { HKWPD } from './segments/HKWPD.js'; import { DKKKU } from './segments/DKKKU.js'; import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; import { CreditCardStatementInteraction } from './interactions/creditcardStatementInteraction.js'; -import { SepaAccountInteraction, SepaAccountResponse } from './interactions/sepaAccountInteraction.js'; export interface SynchronizeResponse extends InitResponse {} diff --git a/src/dataGroups/SepaAccount.ts b/src/dataGroups/SepaAccount.ts index 9d55b12..4e7c53f 100644 --- a/src/dataGroups/SepaAccount.ts +++ b/src/dataGroups/SepaAccount.ts @@ -2,15 +2,13 @@ import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; import { DataGroup } from './DataGroup.js'; import { Identification } from '../dataElements/Identification.js'; import { BankIdentification } from './BankIdentification.js'; -import { Bank } from './Account.js'; +import { Account } from './Account.js'; import { YesNo } from '../dataElements/YesNo.js'; -export type SepaAccount = { +export type SepaAccount = Account & { + isSepaAccount: boolean; iban?: string; bic?: string; - accountNumber?: string; - subAccountId?: string; - bank?: Bank; }; export class SepaAccountGroup extends DataGroup { diff --git a/src/interactions/customerInteraction.ts b/src/interactions/customerInteraction.ts index 0a65117..8861249 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -40,10 +40,15 @@ export abstract class CustomerInteraction { getClientResponse(message: Message): TResponse { const clientResponse = this.handleBaseResponse(message); + const currentBankingInformationSnapshot = JSON.stringify(this.dialog?.config.bankingInformation); + if (clientResponse.success && !clientResponse.requiresTan) { this.handleResponse(message, clientResponse); } + clientResponse.bankingInformationUpdated = + currentBankingInformationSnapshot !== JSON.stringify(this.dialog?.config.bankingInformation); + return clientResponse as TResponse; } diff --git a/src/interactions/initDialogInteraction.ts b/src/interactions/initDialogInteraction.ts index b952fe9..8b46aeb 100644 --- a/src/interactions/initDialogInteraction.ts +++ b/src/interactions/initDialogInteraction.ts @@ -70,8 +70,6 @@ export class InitDialogInteraction extends CustomerInteraction { } handleResponse(response: Message, clientResponse: InitResponse) { - const currentBankingInformationSnapshot = JSON.stringify(this.config.bankingInformation); - const hisyn = response.findSegment(HISYN.Id); if (hisyn && hisyn.systemId) { this.config.bankingInformation.systemId = hisyn.systemId; @@ -175,6 +173,7 @@ export class InitDialogInteraction extends CustomerInteraction { const hiupds = response.findAllSegments(HIUPD.Id); const accounts: BankAccount[] = hiupds.map((upd) => { return { + isSepaAccount: false, accountNumber: upd.account.accountNumber, subAccountId: upd.account.subAccountId, bank: upd.account.bank, @@ -204,8 +203,6 @@ export class InitDialogInteraction extends CustomerInteraction { this.config.bankingInformation.bankMessages = bankMessages; clientResponse.bankingInformation = this.config.bankingInformation; - clientResponse.bankingInformationUpdated = - currentBankingInformationSnapshot !== JSON.stringify(this.config.bankingInformation); } } diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts index 3a5e03a..e9ceb21 100644 --- a/src/interactions/sepaAccountInteraction.ts +++ b/src/interactions/sepaAccountInteraction.ts @@ -1,13 +1,14 @@ -import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './customerInteraction.js'; +import { ClientResponse, CustomerOrderInteraction } from './customerInteraction.js'; import { Message } from '../message.js'; import { Segment } from '../segment.js'; import { HISPA, HISPASegment } from '../segments/HISPA.js'; import { HKSPA, HKSPASegment } from '../segments/HKSPA.js'; -import { InternationalAccount } from '../dataGroups/InternationalAccount.js'; import { FinTSConfig } from '../config.js'; +import { SepaAccount } from '../dataGroups/SepaAccount.js'; +import { sep } from 'path'; export interface SepaAccountResponse extends ClientResponse { - sepaAccounts?: InternationalAccount[]; + sepaAccounts?: SepaAccount[]; } export class SepaAccountInteraction extends CustomerOrderInteraction { @@ -46,6 +47,15 @@ export class SepaAccountInteraction extends CustomerOrderInteraction { const hispa = response.findSegment(HISPA.Id); if (hispa) { clientResponse.sepaAccounts = hispa.sepaAccounts || []; + + clientResponse.sepaAccounts.forEach((sepaAccount) => { + const bankAccount = this.dialog!.config.getBankAccount(sepaAccount.accountNumber); + if (bankAccount) { + bankAccount.isSepaAccount = sepaAccount.isSepaAccount; + bankAccount.iban = sepaAccount.iban; + bankAccount.bic = sepaAccount.bic; + } + }); } } } From d92ccb2de375f37f60ce20c8bb1fb722ebf6f149 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sat, 13 Dec 2025 15:00:55 +0100 Subject: [PATCH 5/8] Remove unused import from sepaAccountInteraction.ts --- src/interactions/sepaAccountInteraction.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts index e9ceb21..8d7636d 100644 --- a/src/interactions/sepaAccountInteraction.ts +++ b/src/interactions/sepaAccountInteraction.ts @@ -5,7 +5,6 @@ import { HISPA, HISPASegment } from '../segments/HISPA.js'; import { HKSPA, HKSPASegment } from '../segments/HKSPA.js'; import { FinTSConfig } from '../config.js'; import { SepaAccount } from '../dataGroups/SepaAccount.js'; -import { sep } from 'path'; export interface SepaAccountResponse extends ClientResponse { sepaAccounts?: SepaAccount[]; From 36c958fc25f42534d381d2b785cf80e39a7d1c34 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 21 Dec 2025 10:45:30 +0100 Subject: [PATCH 6/8] Refactor FinTSClient and Dialog classes to improve interaction handling and response management --- src/client.ts | 120 +++----- src/dialog.ts | 318 ++++++++++++---------- src/interactions/customerInteraction.ts | 2 +- src/interactions/initDialogInteraction.ts | 14 +- src/interactions/tanMediaInteraction.ts | 5 + src/tests/client.test.ts | 66 +++-- 6 files changed, 254 insertions(+), 271 deletions(-) diff --git a/src/client.ts b/src/client.ts index d748c8f..f150e65 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,18 +1,17 @@ import { Dialog } from './dialog.js'; -import { HKTAB } from './segments/HKTAB.js'; import { StatementResponse, StatementInteraction } from './interactions/statementInteraction.js'; import { AccountBalanceResponse, BalanceInteraction } from './interactions/balanceInteraction.js'; import { PortfolioResponse, PortfolioInteraction } from './interactions/portfolioInteraction.js'; import { FinTSConfig } from './config.js'; import { ClientResponse, CustomerOrderInteraction } from './interactions/customerInteraction.js'; -import { TanMediaInteraction, TanMediaResponse } from './interactions/tanMediaInteraction.js'; import { TanMethod } from './tanMethod.js'; import { HKSAL } from './segments/HKSAL.js'; import { HKKAZ } from './segments/HKKAZ.js'; import { HKWPD } from './segments/HKWPD.js'; import { DKKKU } from './segments/DKKKU.js'; -import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; +import { InitResponse } from './interactions/initDialogInteraction.js'; import { CreditCardStatementInteraction } from './interactions/creditcardStatementInteraction.js'; +import { HKIDN } from './segments/HKIDN.js'; export interface SynchronizeResponse extends InitResponse {} @@ -20,7 +19,7 @@ export interface SynchronizeResponse extends InitResponse {} * A client to communicate with a bank over the FinTS protocol */ export class FinTSClient { - private lastDialog: Dialog | undefined; + private currentDialog: Dialog | undefined; /** * Creates a new FinTS client @@ -59,27 +58,9 @@ export class FinTSClient { * @returns the synchronization response */ async synchronize(): Promise { - const syncResponse = await this.initDialog(true); - - if (!syncResponse.success || syncResponse.requiresTan) { - return syncResponse; - } - - if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { - const tanMediaResponse = await this.lastDialog!.startCustomerOrderInteraction( - new TanMediaInteraction() - ); - - let tanMethod = this.config.selectedTanMethod; - if (tanMethod) { - tanMethod.activeTanMedia = tanMediaResponse.tanMediaList; - } - - syncResponse.bankAnswers.push(...tanMediaResponse.bankAnswers); - } - - await this.lastDialog!.end(); - return syncResponse; + this.currentDialog = new Dialog(this.config, true); + const responses = await this.currentDialog.start(); + return responses.get(HKIDN.Id) as SynchronizeResponse; } /** @@ -89,7 +70,8 @@ export class FinTSClient { * @returns the synchronization response */ async synchronizeWithTan(tanReference: string, tan?: string): Promise { - return this.continueCustomerInteractionWithTan(tanReference, tan); + const responses = await this.continueCustomerInteractionWithTan(tanReference, tan); + return responses.get(HKIDN.Id) as SynchronizeResponse; } /** @@ -109,7 +91,8 @@ export class FinTSClient { * @returns the account balance response */ async getAccountBalance(accountNumber: string): Promise { - return this.startCustomerOrderInteraction(new BalanceInteraction(accountNumber)); + const responses = await this.startCustomerOrderInteraction(new BalanceInteraction(accountNumber)); + return responses.get(HKSAL.Id) as AccountBalanceResponse; } /** @@ -119,7 +102,8 @@ export class FinTSClient { * @returns the account balance response */ async getAccountBalanceWithTan(tanReference: string, tan?: string): Promise { - return this.continueCustomerInteractionWithTan(tanReference, tan); + const responses = await this.continueCustomerInteractionWithTan(tanReference, tan); + return responses.get(HKSAL.Id) as AccountBalanceResponse; } /** @@ -141,7 +125,8 @@ export class FinTSClient { * @returns an account statements response containing an array of statements */ async getAccountStatements(accountNumber: string, from?: Date, to?: Date): Promise { - return this.startCustomerOrderInteraction(new StatementInteraction(accountNumber, from, to)); + const responses = await this.startCustomerOrderInteraction(new StatementInteraction(accountNumber, from, to)); + return responses.get(HKKAZ.Id) as StatementResponse; } /** @@ -151,7 +136,8 @@ export class FinTSClient { * @returns an account statements response containing an array of statements */ async getAccountStatementsWithTan(tanReference: string, tan?: string): Promise { - return this.continueCustomerInteractionWithTan(tanReference, tan); + const responses = await this.continueCustomerInteractionWithTan(tanReference, tan); + return responses.get(HKKAZ.Id) as StatementResponse; } /** @@ -179,9 +165,10 @@ export class FinTSClient { priceQuality?: '1' | '2', maxEntries?: number ): Promise { - return this.startCustomerOrderInteraction( + const responses = await this.startCustomerOrderInteraction( new PortfolioInteraction(accountNumber, currency, priceQuality, maxEntries) ); + return responses.get(HKWPD.Id) as PortfolioResponse; } /** @@ -192,7 +179,8 @@ export class FinTSClient { * @returns a portfolio response containing holdings and total value */ async getPortfolioWithTan(tanReference: string, tan?: string): Promise { - return this.continueCustomerInteractionWithTan(tanReference, tan); + const responses = await this.continueCustomerInteractionWithTan(tanReference, tan); + return responses.get(HKWPD.Id) as PortfolioResponse; } /** @@ -215,7 +203,8 @@ export class FinTSClient { * @returns an account statements response containing an array of statements */ async getCreditCardStatements(accountNumber: string, from?: Date): Promise { - return this.startCustomerOrderInteraction(new CreditCardStatementInteraction(accountNumber, from)); + const responses = await this.startCustomerOrderInteraction(new CreditCardStatementInteraction(accountNumber, from)); + return responses.get(DKKKU.Id) as StatementResponse; } /** @@ -225,67 +214,26 @@ export class FinTSClient { * @returns a credit card statements response containing an array of statements */ async getCreditCardStatementsWithTan(tanReference: string, tan?: string): Promise { - return this.continueCustomerInteractionWithTan(tanReference, tan); + const responses = await this.continueCustomerInteractionWithTan(tanReference, tan); + return responses.get(DKKKU.Id) as StatementResponse; } - private async startCustomerOrderInteraction( + private async startCustomerOrderInteraction( interaction: CustomerOrderInteraction - ): Promise { - const syncResponse = await this.initDialog(false, interaction); - - if (!syncResponse.success || syncResponse.requiresTan) { - return syncResponse as TClientResponse; - } - - const clientResponse = await this.lastDialog!.startCustomerOrderInteraction(interaction); - - if (!clientResponse.requiresTan) { - await this.lastDialog!.end(); - } - - return clientResponse; + ): Promise> { + this.currentDialog = new Dialog(this.config, false); + this.currentDialog.addCustomerInteraction(interaction); + return await this.currentDialog.start(); } - private async continueCustomerInteractionWithTan( + private async continueCustomerInteractionWithTan( tanReference: string, tan?: string - ): Promise { - if (!this.lastDialog) { - throw new Error('no customer interaction was started which can continue'); - } - - let clientResponse = await this.lastDialog.continueCustomerOrderInteraction(tanReference, tan); - - if (!clientResponse.success) { - await this.lastDialog.end(); - return clientResponse; - } - - if (clientResponse.requiresTan) { - return clientResponse; + ): Promise> { + if (!this.currentDialog) { + throw new Error('no customer dialog was started which can continue'); } - const initDialogInteraction = this.lastDialog.lastInteraction as InitDialogInteraction; - if (initDialogInteraction.followUpInteraction) { - clientResponse = await this.lastDialog.startCustomerOrderInteraction( - initDialogInteraction.followUpInteraction - ); - - if (clientResponse.requiresTan) { - return clientResponse; - } - } - - await this.lastDialog.end(); - return clientResponse; - } - - private async initDialog( - syncSystemId = false, - followUpInteraction?: CustomerOrderInteraction - ): Promise { - this.lastDialog = new Dialog(this.config); - const interaction = new InitDialogInteraction(this.config, syncSystemId, followUpInteraction); - return await this.lastDialog.initialize(interaction); + return await this.currentDialog.continue(tanReference, tan); } } diff --git a/src/dialog.ts b/src/dialog.ts index f11c374..8c68b00 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -16,85 +16,143 @@ export class Dialog { dialogId: string = '0'; lastMessageNumber = 0; interactions: CustomerInteraction[] = []; + responses: Map = new Map(); + currentInteractionIndex = 0; isInitialized = false; hasEnded = false; httpClient: HttpClient; - constructor(public config: FinTSConfig) { + constructor(public config: FinTSConfig, syncSystemId: boolean = false) { if (!this.config) { throw new Error('configuration must be provided'); } this.httpClient = this.getHttpClient(); + this.addCustomerInteraction(new InitDialogInteraction(this.config, syncSystemId)); } - get lastInteraction(): CustomerInteraction | undefined { - if (this.interactions.length === 0) { - return undefined; - } + get currentInteraction(): CustomerInteraction { + return this.interactions[this.currentInteractionIndex]; + } + + get lastInteraction(): CustomerInteraction { return this.interactions[this.interactions.length - 1]; } - async initialize(interaction: InitDialogInteraction): Promise { + async start(): Promise> { if (this.isInitialized) { throw new Error('dialog has already been initialized'); } if (this.hasEnded) { - throw Error('cannot initialize a dialog that has already ended'); + throw Error('cannot start a dialog that has already ended'); } if (this.lastMessageNumber > 0) { - throw new Error('dialog initialization must be the first message in a dialog'); + throw new Error('dialog start can only be called on a new dialog'); } - interaction.dialog = this; - this.interactions.push(interaction); - this.lastMessageNumber++; - const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); + let clientResponse: ClientResponse; - const tanMethod = this.config.selectedTanMethod; - const isScaSupported = tanMethod && tanMethod.version >= 6; + do { + const message = this.createCurrentCustomerMessage(); + const responseMessage = await this.httpClient.sendMessage(message); + await this.handleMessageContinuations(message, responseMessage, this.currentInteraction); + this.checkEnded(responseMessage); + clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + this.dialogId = clientResponse.dialogId; + this.responses.set(this.currentInteraction.segId, clientResponse); - if (this.config.userId && this.config.pin) { - message.sign( - this.config.countryCode, - this.config.bankId, - this.config.userId, - this.config.pin, - this.config.bankingInformation!.systemId, - isScaSupported ? this.config.tanMethodId : undefined - ); + if (clientResponse.success && !clientResponse.requiresTan) { + this.currentInteractionIndex++; + } + } while ( + !this.hasEnded && + this.currentInteractionIndex < this.interactions.length && + clientResponse.success && + !clientResponse.requiresTan + ); + + if (!this.hasEnded && this.currentInteractionIndex === this.interactions.length) { + await this.end(); } - const segments = interaction.getSegments(this.config); - segments.forEach((segment) => message.addSegment(segment)); + return this.responses; + } - if (this.config.userId && this.config.pin && isScaSupported) { - const hktan: HKTANSegment = { - header: { segId: HKTAN.Id, segNr: 0, version: tanMethod.version }, - tanProcess: TanProcess.Process4, - segId: HKIDN.Id, - }; + async continue(tanOrderReference: string, tan?: string): Promise> { + if (!tanOrderReference) { + throw Error('tanOrderReference must be provided to continue a customer order with a TAN'); + } - message.addSegment(hktan); + if (!this.config.selectedTanMethod?.isDecoupled && !tan) { + throw Error('TAN must be provided for non-decoupled TAN methods'); + } + + if (this.hasEnded) { + throw Error('cannot continue a customer order when dialog has already ended'); + } + + if (this.currentInteractionIndex >= this.interactions.length) { + throw new Error('there is no running customer interaction in this dialog to continue'); + } + + let clientResponse: ClientResponse; + + let isFirstMessage = true; + + do { + const message = isFirstMessage + ? this.createCurrentTanMessage(tanOrderReference, tan) + : this.createCurrentCustomerMessage(); + const responseMessage = await this.httpClient.sendMessage(message); + await this.handleMessageContinuations(message, responseMessage, this.currentInteraction); + this.checkEnded(responseMessage); + clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + this.dialogId = clientResponse.dialogId; + this.responses.set(this.currentInteraction.segId, clientResponse); + + if (clientResponse.success && !clientResponse.requiresTan) { + this.currentInteractionIndex++; + } + + isFirstMessage = false; + } while ( + !this.hasEnded && + this.currentInteractionIndex < this.interactions.length && + clientResponse.success && + !clientResponse.requiresTan + ); + + if (!this.hasEnded && this.currentInteractionIndex === this.interactions.length) { + await this.end(); } - const initResponse = await this.httpClient.sendMessage(message); + return this.responses; + } + + addCustomerInteraction(interaction: CustomerInteraction, afterCurrent = false): void { + if (this.hasEnded) { + throw Error('cannot queue another customer interaction when dialog has already ended'); + } - const clientResponse = interaction.getClientResponse(initResponse); - this.dialogId = clientResponse.dialogId; + const isCustomerOrder = this.currentInteraction instanceof CustomerOrderInteraction; - if (clientResponse.success) { - this.isInitialized = true; + if (isCustomerOrder && !this.config.isTransactionSupported(interaction.segId)) { + throw Error(`customer order transaction ${interaction.segId} is not supported according to the BPD`); } - this.checkEnded(initResponse); + interaction.dialog = this; + + if (afterCurrent) { + this.interactions.splice(this.currentInteractionIndex + 1, 0, interaction); + return; + } - return clientResponse; + this.interactions.push(interaction); } - async end(): Promise { + private async end(): Promise { if (!this.isInitialized || this.hasEnded) { return true; } @@ -102,10 +160,6 @@ export class Dialog { const tanMethod = this.config.selectedTanMethod; const isScaSupported = tanMethod && tanMethod.version >= 6; - if (this.config.tanMethodId && !tanMethod) { - throw new Error('given tanMethodId is not available according to the BPD'); - } - this.lastMessageNumber++; const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); @@ -133,34 +187,30 @@ export class Dialog { return this.hasEnded; } - async startCustomerOrderInteraction( - interaction: CustomerOrderInteraction - ): Promise { - if (!this.isInitialized) { - throw Error('dialog must be initialized before sending a customer order'); - } + private createCurrentCustomerMessage(): CustomerMessage { + this.lastMessageNumber++; - if (this.hasEnded) { - throw Error('cannot send a customer message when dialog has already ended'); - } + const isCustomerOrder = this.currentInteraction instanceof CustomerOrderInteraction; + const message = isCustomerOrder + ? new CustomerOrderMessage( + this.currentInteraction.segId, + this.currentInteraction.responseSegId, + this.dialogId, + this.lastMessageNumber + ) + : new CustomerMessage(this.dialogId, this.lastMessageNumber); - const bankTransaction = this.config.bankingInformation.bpd?.allowedTransactions.find( - (t) => t.transId === interaction.segId - ); + const tanMethod = this.config.selectedTanMethod; + let isScaSupported = tanMethod && tanMethod.version >= 6; + let isTanMethodNeeded = isScaSupported; - if (!bankTransaction) { - throw Error(`transaction ${interaction.segId} is not supported according to the BPD`); - } + if (isCustomerOrder) { + const bankTransaction = this.config.bankingInformation.bpd?.allowedTransactions.find( + (t) => t.transId === this.currentInteraction.segId + ); - interaction.dialog = this; - this.interactions.push(interaction); - this.lastMessageNumber++; - const message = new CustomerOrderMessage( - interaction.segId, - interaction.responseSegId, - this.dialogId, - this.lastMessageNumber - ); + isTanMethodNeeded = isScaSupported && bankTransaction?.tanRequired; + } if (this.config.userId && this.config.pin) { message.sign( @@ -169,93 +219,27 @@ export class Dialog { this.config.userId, this.config.pin, this.config.bankingInformation.systemId, - this.config.selectedTanMethod && (bankTransaction.tanRequired || this.config.selectedTanMethod.version >= 6) - ? this.config.tanMethodId - : undefined + isScaSupported ? this.config.tanMethodId : undefined ); } - const segments = interaction.getSegments(this.config); + const segments = this.currentInteraction.getSegments(this.config); segments.forEach((segment) => message.addSegment(segment)); - if (bankTransaction.tanRequired) { - if (this.config.userId && this.config.pin && this.config.tanMethodId) { - const hktan: HKTANSegment = { - header: { segId: HKTAN.Id, segNr: 0, version: this.config.selectedTanMethod!.version }, - tanProcess: TanProcess.Process4, - segId: interaction.segId, - tanMedia: this.config.tanMediaName, - }; - - message.addSegment(hktan); - } - } - - let responseMessage = await this.httpClient.sendMessage(message); - - let partedSegment = responseMessage.findSegment(PARTED.Id); - - if (partedSegment) { - while (responseMessage.hasReturnCode(3040)) { - const answers = responseMessage.getBankAnswers(); - const segmentWithContinuation = segments.find( - (s) => s.header.segId === interaction.segId - ) as SegmentWithContinuationMark; - if (!segmentWithContinuation) { - throw new Error( - `Response contains segment with further information, but corresponding segment could not be found or is not specified` - ); - } - - segmentWithContinuation.continuationMark = answers.find((a) => a.code === 3040)!.params![0]; - message.findSegment(HNHBK.Id)!.msgNr = ++this.lastMessageNumber; - const nextResponseMessage = await this.httpClient.sendMessage(message); - const nextPartedSegment = nextResponseMessage.findSegment(PARTED.Id); - - if (nextPartedSegment) { - nextPartedSegment.rawData = - partedSegment.rawData + nextPartedSegment.rawData.slice(nextPartedSegment.rawData.indexOf('+') + 1); - partedSegment = nextPartedSegment; - } - - responseMessage = nextResponseMessage; - } + if (this.config.userId && this.config.pin && isTanMethodNeeded) { + const hktan: HKTANSegment = { + header: { segId: HKTAN.Id, segNr: 0, version: tanMethod!.version }, + tanProcess: TanProcess.Process4, + segId: HKIDN.Id, + }; - const completeSegment = decode(partedSegment.rawData); - const index = responseMessage.segments.indexOf(partedSegment); - responseMessage.segments.splice(index, 1, completeSegment); + message.addSegment(hktan); } - this.checkEnded(responseMessage); - - return interaction.getClientResponse(responseMessage); + return message; } - async continueCustomerOrderInteraction( - tanOrderReference: string, - tan?: string - ): Promise { - if (!tanOrderReference) { - throw Error('tanOrderReference must be provided to continue a customer order with a TAN'); - } - - if (!this.config.selectedTanMethod?.isDecoupled && !tan) { - throw Error('TAN must be provided for non-decoupled TAN methods'); - } - - if (!this.isInitialized) { - throw Error('dialog must be initialized before sending a TAN message'); - } - - if (this.hasEnded) { - throw Error('cannot continue a customer order when dialog has already ended'); - } - - if (this.interactions.length === 0) { - throw new Error('No running customer interaction found to continue'); - } - - const interaction = this.interactions[this.interactions.length - 1]; + private createCurrentTanMessage(tanOrderReference: string, tan?: string): CustomerMessage { this.lastMessageNumber++; const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); @@ -275,7 +259,7 @@ export class Dialog { const hktan: HKTANSegment = { header: { segId: HKTAN.Id, segNr: 0, version: this.config.selectedTanMethod!.version }, tanProcess: this.config.selectedTanMethod?.isDecoupled ? TanProcess.Status : TanProcess.Process2, - segId: interaction.segId, + segId: this.currentInteraction.segId, orderRef: tanOrderReference, nextTan: false, tanMedia: @@ -286,10 +270,46 @@ export class Dialog { message.addSegment(hktan); } + return message; + } - const responseMessage = await this.httpClient.sendMessage(message); - this.checkEnded(responseMessage); - return interaction.getClientResponse(responseMessage) as TClientResponse; + private async handleMessageContinuations( + message: CustomerMessage, + responseMessage: Message, + interaction: CustomerInteraction + ) { + let partedSegment = responseMessage.findSegment(PARTED.Id); + + if (partedSegment) { + while (responseMessage.hasReturnCode(3040)) { + const answers = responseMessage.getBankAnswers(); + const segmentWithContinuation = message.segments.find( + (s) => s.header.segId === interaction.segId + ) as SegmentWithContinuationMark; + if (!segmentWithContinuation) { + throw new Error( + `Response contains segment with further information, but corresponding segment could not be found or is not specified` + ); + } + + segmentWithContinuation.continuationMark = answers.find((a) => a.code === 3040)!.params![0]; + message.findSegment(HNHBK.Id)!.msgNr = ++this.lastMessageNumber; + const nextResponseMessage = await this.httpClient.sendMessage(message); + const nextPartedSegment = nextResponseMessage.findSegment(PARTED.Id); + + if (nextPartedSegment) { + nextPartedSegment.rawData = + partedSegment.rawData + nextPartedSegment.rawData.slice(nextPartedSegment.rawData.indexOf('+') + 1); + partedSegment = nextPartedSegment; + } + + responseMessage = nextResponseMessage; + } + + const completeSegment = decode(partedSegment.rawData); + const index = responseMessage.segments.indexOf(partedSegment); + responseMessage.segments.splice(index, 1, completeSegment); + } } private checkEnded(initResponse: Message) { diff --git a/src/interactions/customerInteraction.ts b/src/interactions/customerInteraction.ts index 8861249..86f76d4 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -37,7 +37,7 @@ export abstract class CustomerInteraction { return this.createSegments(config); } - getClientResponse(message: Message): TResponse { + handleClientResponse(message: Message): TResponse { const clientResponse = this.handleBaseResponse(message); const currentBankingInformationSnapshot = JSON.stringify(this.dialog?.config.bankingInformation); diff --git a/src/interactions/initDialogInteraction.ts b/src/interactions/initDialogInteraction.ts index 8b46aeb..3993ded 100644 --- a/src/interactions/initDialogInteraction.ts +++ b/src/interactions/initDialogInteraction.ts @@ -19,17 +19,15 @@ import { HIUPA, HIUPASegment } from '../segments/HIUPA.js'; import { BankAccount, finTsAccountTypeToEnum } from '../bankAccount.js'; import { HIKIMSegment, HIKIM } from '../segments/HIKIM.js'; import { HIUPDSegment, HIUPD } from '../segments/HIUPD.js'; +import { HKTAB } from '../segments/HKTAB.js'; +import { TanMediaInteraction } from './tanMediaInteraction.js'; export interface InitResponse extends ClientResponse { bankingInformation?: BankingInformation; } export class InitDialogInteraction extends CustomerInteraction { - constructor( - public config: FinTSConfig, - public syncSystemId = false, - public followUpInteraction?: CustomerOrderInteraction - ) { + constructor(public config: FinTSConfig, public syncSystemId = false) { super(HKIDN.Id); } @@ -203,6 +201,12 @@ export class InitDialogInteraction extends CustomerInteraction { this.config.bankingInformation.bankMessages = bankMessages; clientResponse.bankingInformation = this.config.bankingInformation; + + if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { + this.dialog!.addCustomerInteraction(new TanMediaInteraction(), true); + } + + this.dialog!.isInitialized = true; } } diff --git a/src/interactions/tanMediaInteraction.ts b/src/interactions/tanMediaInteraction.ts index f3f5781..1e6a54c 100644 --- a/src/interactions/tanMediaInteraction.ts +++ b/src/interactions/tanMediaInteraction.ts @@ -37,6 +37,11 @@ export class TanMediaInteraction extends CustomerOrderInteraction { clientResponse.tanMediaList = (hitab.mediaList ?? []) .map((media) => media.name) .filter((name) => name) as string[]; + + let tanMethod = this.dialog!.config.selectedTanMethod; + if (tanMethod) { + tanMethod.activeTanMedia = clientResponse.tanMediaList; + } } } } diff --git a/src/tests/client.test.ts b/src/tests/client.test.ts index f1158f8..24a988f 100644 --- a/src/tests/client.test.ts +++ b/src/tests/client.test.ts @@ -1,43 +1,49 @@ import { MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FinTSClient } from '../client.js'; import { Dialog } from '../dialog.js'; -import { InitDialogInteraction, InitResponse } from '../interactions/initDialogInteraction.js'; import { FinTSConfig } from '../config.js'; describe('FinTSClient', () => { - let dialogInitializeMock: MockInstance<[interaction: InitDialogInteraction], Promise>; - let dialogEndMock: MockInstance<[], Promise>; + let dialogStartMock: MockInstance; + let dialogContinueMock: MockInstance; - beforeEach(() => { - dialogInitializeMock = vi.spyOn(Dialog.prototype, 'initialize'); - dialogEndMock = vi.spyOn(Dialog.prototype, 'end'); - }); + beforeEach(() => { + dialogStartMock = vi.spyOn(Dialog.prototype, 'start'); + dialogContinueMock = vi.spyOn(Dialog.prototype, 'continue'); + }); - afterEach(() => { - dialogInitializeMock.mockRestore(); - dialogEndMock.mockRestore(); - }); + afterEach(() => { + dialogStartMock.mockRestore(); + dialogContinueMock.mockRestore(); + }); - it('sends a message', async () => { - const client = new FinTSClient( - FinTSConfig.forFirstTimeUse('product', '1.0', 'http://localhost', '12030000', 'user') - ); + it('sends a message', async () => { + const client = new FinTSClient( + FinTSConfig.forFirstTimeUse('product', '1.0', 'http://localhost', '12030000', 'user') + ); - dialogInitializeMock.mockResolvedValueOnce({ - dialogId: 'DIALOG1', - success: true, - requiresTan: false, - bankingInformationUpdated: true, - bankingInformation: { systemId: 'SYSTEM01', bankMessages: [] }, - bankAnswers: [{ code: 20, text: 'Auftrag ausgeführt' }], - }); + dialogStartMock.mockResolvedValueOnce( + new Map([ + [ + 'HKIDN', + { + dialogId: 'DIALOG1', + success: true, + requiresTan: false, + bankingInformationUpdated: true, + bankingInformation: { systemId: 'SYSTEM01', bankMessages: [] }, + bankAnswers: [{ code: 20, text: 'Auftrag ausgeführt' }], + }, + ], + ]) + ); - const response = await client.synchronize(); + const response = await client.synchronize(); - expect(response.success).toBe(true); - expect(response.requiresTan).toBe(false); - expect(response.bankingInformation).toBeDefined(); - expect(dialogInitializeMock).toHaveBeenCalledOnce(); - expect(dialogEndMock).toHaveBeenCalledOnce(); - }); + expect(response.success).toBe(true); + expect(response.requiresTan).toBe(false); + expect(response.bankingInformation).toBeDefined(); + expect(dialogStartMock).toHaveBeenCalledOnce(); + expect(dialogContinueMock).toHaveBeenCalledTimes(0); + }); }); From 45b623b4d6f9c0e233747b6d3295e480afa8e1f4 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 21 Dec 2025 14:41:02 +0100 Subject: [PATCH 7/8] Refactor Dialog and CustomerInteraction classes. add EndDialogInteraction class and add Dialog tests --- src/dialog.ts | 90 ++----- src/interactions/customerInteraction.ts | 4 +- src/interactions/endDialogInteraction.ts | 28 ++ src/interactions/initDialogInteraction.ts | 2 - src/tests/dialog.test.ts | 309 ++++++++++++++++++++++ 5 files changed, 368 insertions(+), 65 deletions(-) create mode 100644 src/interactions/endDialogInteraction.ts create mode 100644 src/tests/dialog.test.ts diff --git a/src/dialog.ts b/src/dialog.ts index 8c68b00..c5338a5 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -3,7 +3,6 @@ import { TanMediaRequirement, TanProcess } from './codes.js'; import { HttpClient } from './httpClient.js'; import { CustomerMessage, CustomerOrderMessage, Message } from './message.js'; import { SegmentWithContinuationMark } from './segment.js'; -import { HKEND, HKENDSegment } from './segments/HKEND.js'; import { HKIDN } from './segments/HKIDN.js'; import { HKTAN, HKTANSegment } from './segments/HKTAN.js'; import { HNHBK, HNHBKSegment } from './segments/HNHBK.js'; @@ -11,6 +10,7 @@ import { decode } from './segment.js'; import { PARTED, PartedSegment } from './partedSegment.js'; import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './interactions/customerInteraction.js'; import { InitDialogInteraction, InitResponse } from './interactions/initDialogInteraction.js'; +import { EndDialogInteraction } from './interactions/endDialogInteraction.js'; export class Dialog { dialogId: string = '0'; @@ -28,17 +28,17 @@ export class Dialog { } this.httpClient = this.getHttpClient(); - this.addCustomerInteraction(new InitDialogInteraction(this.config, syncSystemId)); + this.interactions.push(new InitDialogInteraction(this.config, syncSystemId)); + this.interactions.push(new EndDialogInteraction()); + this.interactions.forEach((interaction) => { + interaction.dialog = this; + }); } get currentInteraction(): CustomerInteraction { return this.interactions[this.currentInteractionIndex]; } - get lastInteraction(): CustomerInteraction { - return this.interactions[this.interactions.length - 1]; - } - async start(): Promise> { if (this.isInitialized) { throw new Error('dialog has already been initialized'); @@ -57,14 +57,18 @@ export class Dialog { do { const message = this.createCurrentCustomerMessage(); const responseMessage = await this.httpClient.sendMessage(message); - await this.handleMessageContinuations(message, responseMessage, this.currentInteraction); - this.checkEnded(responseMessage); - clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + await this.handlePartedMessages(message, responseMessage, this.currentInteraction); + clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + this.checkEnded(clientResponse); this.dialogId = clientResponse.dialogId; this.responses.set(this.currentInteraction.segId, clientResponse); if (clientResponse.success && !clientResponse.requiresTan) { this.currentInteractionIndex++; + + if (this.currentInteractionIndex > 0) { + this.isInitialized = true; + } } } while ( !this.hasEnded && @@ -73,10 +77,6 @@ export class Dialog { !clientResponse.requiresTan ); - if (!this.hasEnded && this.currentInteractionIndex === this.interactions.length) { - await this.end(); - } - return this.responses; } @@ -93,7 +93,7 @@ export class Dialog { throw Error('cannot continue a customer order when dialog has already ended'); } - if (this.currentInteractionIndex >= this.interactions.length) { + if (!this.currentInteraction) { throw new Error('there is no running customer interaction in this dialog to continue'); } @@ -106,14 +106,18 @@ export class Dialog { ? this.createCurrentTanMessage(tanOrderReference, tan) : this.createCurrentCustomerMessage(); const responseMessage = await this.httpClient.sendMessage(message); - await this.handleMessageContinuations(message, responseMessage, this.currentInteraction); - this.checkEnded(responseMessage); - clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + await this.handlePartedMessages(message, responseMessage, this.currentInteraction); + clientResponse = this.currentInteraction.handleClientResponse(responseMessage); + this.checkEnded(clientResponse); this.dialogId = clientResponse.dialogId; this.responses.set(this.currentInteraction.segId, clientResponse); if (clientResponse.success && !clientResponse.requiresTan) { this.currentInteractionIndex++; + + if (this.currentInteractionIndex > 0) { + this.isInitialized = true; + } } isFirstMessage = false; @@ -124,10 +128,6 @@ export class Dialog { !clientResponse.requiresTan ); - if (!this.hasEnded && this.currentInteractionIndex === this.interactions.length) { - await this.end(); - } - return this.responses; } @@ -136,7 +136,7 @@ export class Dialog { throw Error('cannot queue another customer interaction when dialog has already ended'); } - const isCustomerOrder = this.currentInteraction instanceof CustomerOrderInteraction; + const isCustomerOrder = interaction instanceof CustomerOrderInteraction; if (isCustomerOrder && !this.config.isTransactionSupported(interaction.segId)) { throw Error(`customer order transaction ${interaction.segId} is not supported according to the BPD`); @@ -149,42 +149,7 @@ export class Dialog { return; } - this.interactions.push(interaction); - } - - private async end(): Promise { - if (!this.isInitialized || this.hasEnded) { - return true; - } - - const tanMethod = this.config.selectedTanMethod; - const isScaSupported = tanMethod && tanMethod.version >= 6; - - this.lastMessageNumber++; - const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); - - if (this.config.userId && this.config.pin) { - message.sign( - this.config.countryCode, - this.config.bankId, - this.config.userId, - this.config.pin, - this.config.bankingInformation.systemId, - isScaSupported ? this.config.tanMethodId : undefined - ); - } - - const hkend: HKENDSegment = { - header: { segId: HKEND.Id, segNr: 0, version: HKEND.Version }, - dialogId: this.dialogId, - }; - - message.addSegment(hkend); - - const responseMessage = await this.httpClient.sendMessage(message); - - this.checkEnded(responseMessage); - return this.hasEnded; + this.interactions.splice(this.interactions.length - 1, 0, interaction); } private createCurrentCustomerMessage(): CustomerMessage { @@ -273,7 +238,7 @@ export class Dialog { return message; } - private async handleMessageContinuations( + private async handlePartedMessages( message: CustomerMessage, responseMessage: Message, interaction: CustomerInteraction @@ -312,8 +277,11 @@ export class Dialog { } } - private checkEnded(initResponse: Message) { - if (initResponse.hasReturnCode(100) || initResponse.hasReturnCode(9800)) { + private checkEnded(response: ClientResponse) { + if ( + response.bankAnswers.some((answer) => answer.code === 100) || + response.bankAnswers.some((answer) => answer.code === 9000) + ) { this.hasEnded = true; } } diff --git a/src/interactions/customerInteraction.ts b/src/interactions/customerInteraction.ts index 86f76d4..e98457e 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -37,7 +37,7 @@ export abstract class CustomerInteraction { return this.createSegments(config); } - handleClientResponse(message: Message): TResponse { + handleClientResponse(message: Message): ClientResponse { const clientResponse = this.handleBaseResponse(message); const currentBankingInformationSnapshot = JSON.stringify(this.dialog?.config.bankingInformation); @@ -49,7 +49,7 @@ export abstract class CustomerInteraction { clientResponse.bankingInformationUpdated = currentBankingInformationSnapshot !== JSON.stringify(this.dialog?.config.bankingInformation); - return clientResponse as TResponse; + return clientResponse; } protected abstract createSegments(config: FinTSConfig): Segment[]; diff --git a/src/interactions/endDialogInteraction.ts b/src/interactions/endDialogInteraction.ts new file mode 100644 index 0000000..b7a50d8 --- /dev/null +++ b/src/interactions/endDialogInteraction.ts @@ -0,0 +1,28 @@ +import { ClientResponse, CustomerInteraction, CustomerOrderInteraction } from './customerInteraction.js'; +import { Message } from '../message.js'; +import { Segment } from '../segment.js'; +import { FinTSConfig } from '../config.js'; +import { HKEND, HKENDSegment } from '../segments/HKEND.js'; + +export interface TanMediaResponse extends ClientResponse { + tanMediaList: string[]; +} + +export class EndDialogInteraction extends CustomerInteraction { + constructor() { + super(HKEND.Id); + } + + createSegments(init: FinTSConfig): Segment[] { + const hkend: HKENDSegment = { + header: { segId: HKEND.Id, segNr: 0, version: HKEND.Version }, + dialogId: this.dialog!.dialogId, + }; + + return [hkend]; + } + + handleResponse(response: Message, clientResponse: ClientResponse) { + // no special response handling needed + } +} diff --git a/src/interactions/initDialogInteraction.ts b/src/interactions/initDialogInteraction.ts index 3993ded..8024190 100644 --- a/src/interactions/initDialogInteraction.ts +++ b/src/interactions/initDialogInteraction.ts @@ -205,8 +205,6 @@ export class InitDialogInteraction extends CustomerInteraction { if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { this.dialog!.addCustomerInteraction(new TanMediaInteraction(), true); } - - this.dialog!.isInitialized = true; } } diff --git a/src/tests/dialog.test.ts b/src/tests/dialog.test.ts new file mode 100644 index 0000000..feecb3d --- /dev/null +++ b/src/tests/dialog.test.ts @@ -0,0 +1,309 @@ +import { MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Dialog } from '../dialog.js'; +import { FinTSConfig } from '../config.js'; +import { Message, CustomerMessage } from '../message.js'; +import { InitDialogInteraction } from '../interactions/initDialogInteraction.js'; +import { ClientResponse, CustomerOrderInteraction } from '../interactions/customerInteraction.js'; +import { TanMethod } from '../tanMethod.js'; +import { Language, TanMediaRequirement } from '../codes.js'; +import { BankingInformation } from '../bankingInformation.js'; +import { BPD } from '../bpd.js'; +import { BankTransaction } from '../bankTransaction.js'; +import { SepaAccountInteraction } from '../interactions/sepaAccountInteraction.js'; +import { registerSegments } from '../segments/registry.js'; + +// Mock HttpClient to prevent real HTTP calls +vi.mock('../httpClient.js', () => ({ + HttpClient: vi.fn().mockImplementation(() => ({ + sendMessage: vi.fn(), + })), +})); + +describe('Dialog', () => { + let config: FinTSConfig; + let dialog: Dialog; + let httpClientSendMessageMock: MockInstance; + + registerSegments(); + + beforeEach(() => { + const bankingInformation: BankingInformation = { + systemId: 'MOCK_SYSTEM_ID', + bankMessages: [], + bpd: { + version: 1, + bankId: '12030000', + bankName: 'Mock Bank', + countryCode: 280, + url: 'http://mock.bank.url', + allowedTransactions: [ + { + transId: 'HKSPA', + tanRequired: false, + versions: [1, 2, 3], + } as BankTransaction, + ], + supportedTanMethods: [ + { + id: 940, + tanProcess: 1, + name: 'chipTAN', + version: 6, + isDecoupled: false, + activeTanMediaCount: 1, + activeTanMedia: ['TEST_MEDIA'], + tanMediaRequirement: TanMediaRequirement.Optional, + } as TanMethod, + ], + availableTanMethodIds: [940], + supportedLanguages: [Language.German], + maxTransactionsPerMessage: 1, + } as BPD, + }; + + config = FinTSConfig.fromBankingInformation( + 'TestProduct', + '1.0', + bankingInformation, + 'testuser', + '12345', + 940, // tanMethodId + 'TEST_MEDIA' // tanMediaName + ); + + // Create dialog instance + dialog = new Dialog(config); + + vi.spyOn(dialog.currentInteraction, 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: false, + bankAnswers: [{ code: 20, text: 'Success' }], + } as any); + + vi.spyOn(dialog.interactions[dialog.interactions.length - 1], 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: false, + bankAnswers: [{ code: 100, text: 'Dialog ended' }], + } as any); + + // Mock the HttpClient.sendMessage method + httpClientSendMessageMock = vi.mocked(dialog.httpClient.sendMessage); + const responseMessage: Message = new Message([]); + httpClientSendMessageMock.mockResolvedValue(responseMessage); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('creates a new dialog with provided config', () => { + expect(dialog.config).toBe(config); + expect(dialog.dialogId).toBe('0'); + expect(dialog.lastMessageNumber).toBe(0); + expect(dialog.interactions).toHaveLength(2); + expect(dialog.interactions[0]).toBeInstanceOf(InitDialogInteraction); + expect(dialog.currentInteractionIndex).toBe(0); + expect(dialog.isInitialized).toBe(false); + expect(dialog.hasEnded).toBe(false); + expect(dialog.httpClient.sendMessage).toBeDefined(); + }); + + it('creates dialog with syncSystemId option', () => { + const syncDialog = new Dialog(config, true); + const initDialogInteraction = syncDialog.interactions[0] as InitDialogInteraction; + expect(initDialogInteraction).toBeInstanceOf(InitDialogInteraction); + expect(initDialogInteraction.syncSystemId).toBe(true); + }); + + it('throws error when no config is provided', () => { + expect(() => new Dialog(null as any)).toThrow('configuration must be provided'); + }); + }); + + describe('currentInteraction getter', () => { + it('returns the current interaction', () => { + expect(dialog.currentInteraction).toBeInstanceOf(InitDialogInteraction); + }); + }); + + describe('start()', () => { + it('successfully starts and ends a dialog', async () => { + const sepaInteraction = new SepaAccountInteraction(); + + vi.spyOn(sepaInteraction, 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: false, + bankAnswers: [{ code: 20, text: 'Success' }], + } as any); + + dialog.addCustomerInteraction(sepaInteraction); + const responses = await dialog.start(); + + expect(dialog.dialogId).toBe('MOCK_DIALOG_123'); + expect(httpClientSendMessageMock).toHaveBeenCalledTimes(3); + expect(responses).toBeInstanceOf(Map); + expect(responses.size).toBe(3); + expect(dialog.currentInteraction).toBeUndefined(); + expect(dialog.hasEnded).toBe(true); + }); + + it('throws error when dialog is already initialized', async () => { + dialog.isInitialized = true; + + await expect(dialog.start()).rejects.toThrow('dialog has already been initialized'); + }); + + it('throws error when dialog has ended', async () => { + dialog.hasEnded = true; + + await expect(dialog.start()).rejects.toThrow('cannot start a dialog that has already ended'); + }); + + it('throws error when lastMessageNumber > 0', async () => { + dialog.lastMessageNumber = 1; + + await expect(dialog.start()).rejects.toThrow('dialog start can only be called on a new dialog'); + }); + + it('handles TAN requirement correctly', async () => { + vi.spyOn(dialog.currentInteraction, 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: true, + tanReference: 'TAN_REF_123', + bankAnswers: [{ code: 3955, text: 'TAN required' }], + } as ClientResponse); + + const responses = await dialog.start(); + + expect(dialog.currentInteraction).toBeInstanceOf(InitDialogInteraction); + expect(dialog.isInitialized).toBe(false); + expect(dialog.hasEnded).toBe(false); + const response = responses.get(dialog.currentInteraction.segId); + expect(response).toBeDefined(); + expect(response!.requiresTan).toBe(true); + expect(response!.tanReference).toBe('TAN_REF_123'); + }); + }); + + describe('continue()', () => { + beforeEach(async () => { + vi.spyOn(dialog.currentInteraction, 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: true, + tanReference: 'TAN_REF_123', + bankAnswers: [{ code: 3955, text: 'TAN required' }], + } as ClientResponse); + + await dialog.start(); + + vi.spyOn(dialog.currentInteraction, 'handleClientResponse').mockReturnValue({ + dialogId: 'MOCK_DIALOG_123', + success: true, + requiresTan: false, + bankAnswers: [{ code: 20, text: 'Dialog initialized' }], + } as ClientResponse); + }); + + it('successfully continues with TAN', async () => { + const responses = await dialog.continue('TAN_REF_123', '123456'); + + expect(responses).toBeInstanceOf(Map); + expect(dialog.dialogId).toBe('MOCK_DIALOG_123'); + expect(httpClientSendMessageMock).toHaveBeenCalledTimes(3); + expect(responses).toBeInstanceOf(Map); + expect(responses.size).toBe(2); + expect(dialog.currentInteraction).toBeUndefined(); + expect(dialog.hasEnded).toBe(true); + }); + + it('successfully continues decoupled TAN method without TAN', async () => { + dialog.config.bankingInformation.bpd!.supportedTanMethods[0].isDecoupled = true; + + const responses = await dialog.continue('TAN_REF_123'); + expect(responses).toBeInstanceOf(Map); + expect(responses.size).toBe(2); + expect(dialog.currentInteraction).toBeUndefined(); + expect(dialog.hasEnded).toBe(true); + }); + + it('throws error when tanOrderReference is missing', async () => { + await expect(dialog.continue('')).rejects.toThrow( + 'tanOrderReference must be provided to continue a customer order with a TAN' + ); + }); + + it('throws error when TAN is missing for non-decoupled method', async () => { + // The default config already has a non-decoupled TAN method + await expect(dialog.continue('TAN_REF_123')).rejects.toThrow( + 'TAN must be provided for non-decoupled TAN methods' + ); + }); + + it('throws error when dialog has ended', async () => { + dialog.hasEnded = true; + + await expect(dialog.continue('TAN_REF_123', '123456')).rejects.toThrow( + 'cannot continue a customer order when dialog has already ended' + ); + }); + + it('throws error when no current interaction', async () => { + dialog.currentInteractionIndex = dialog.interactions.length; + + await expect(dialog.continue('TAN_REF_123', '123456')).rejects.toThrow( + 'there is no running customer interaction in this dialog to continue' + ); + }); + }); + + describe('addCustomerInteraction()', () => { + let sepaAccountInteraction: CustomerOrderInteraction; + + beforeEach(() => { + sepaAccountInteraction = new SepaAccountInteraction(); + }); + + it('adds interaction to the end but before dialog end interaction by default', () => { + const initialLength = dialog.interactions.length; + + dialog.addCustomerInteraction(sepaAccountInteraction); + + expect(dialog.interactions).toHaveLength(initialLength + 1); + expect(dialog.interactions[dialog.interactions.length - 2]).toBe(sepaAccountInteraction); + expect(sepaAccountInteraction.dialog).toBe(dialog); + }); + + it('adds interaction after current when afterCurrent is true', () => { + const initialLength = dialog.interactions.length; + const currentIndex = dialog.currentInteractionIndex; + + dialog.addCustomerInteraction(sepaAccountInteraction, true); + + expect(dialog.interactions).toHaveLength(initialLength + 1); + expect(dialog.interactions[currentIndex + 1]).toBe(sepaAccountInteraction); + }); + + it('throws error when dialog has ended', () => { + dialog.hasEnded = true; + + expect(() => dialog.addCustomerInteraction(sepaAccountInteraction)).toThrow( + 'cannot queue another customer interaction when dialog has already ended' + ); + }); + + it('throws error for unsupported transaction', () => { + // Mock unsupported transaction + sepaAccountInteraction.segId = 'UNSUPPORTED'; + + expect(() => dialog.addCustomerInteraction(sepaAccountInteraction)).toThrow( + 'customer order transaction UNSUPPORTED is not supported according to the BPD' + ); + }); + }); +}); From be68c6061d2fcddac8c9645009d46aa13e0b9c01 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 21 Dec 2025 15:37:46 +0100 Subject: [PATCH 8/8] fetch SEPA account information when necessary after dialog initialization and update accounts in UPD with SEPA info --- src/dataGroups/SepaAccount.ts | 2 +- src/interactions/initDialogInteraction.ts | 22 +++++++++++++++++++--- src/interactions/sepaAccountInteraction.ts | 4 ++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/dataGroups/SepaAccount.ts b/src/dataGroups/SepaAccount.ts index 4e7c53f..5907703 100644 --- a/src/dataGroups/SepaAccount.ts +++ b/src/dataGroups/SepaAccount.ts @@ -6,7 +6,7 @@ import { Account } from './Account.js'; import { YesNo } from '../dataElements/YesNo.js'; export type SepaAccount = Account & { - isSepaAccount: boolean; + isSepaAccount?: boolean; iban?: string; bic?: string; }; diff --git a/src/interactions/initDialogInteraction.ts b/src/interactions/initDialogInteraction.ts index 8024190..bd01138 100644 --- a/src/interactions/initDialogInteraction.ts +++ b/src/interactions/initDialogInteraction.ts @@ -5,7 +5,7 @@ import { FinTSConfig } from '../config.js'; import { HKIDN, HKIDNSegment } from '../segments/HKIDN.js'; import { BankMessage, BankingInformation } from '../bankingInformation.js'; import { HKVVB, HKVVBSegment } from '../segments/HKVVB.js'; -import { Language, SyncMode } from '../codes.js'; +import { Language, SyncMode, TanMediaRequirement } from '../codes.js'; import { HKSYN, HKSYNSegment } from '../segments/HKSYN.js'; import { HISYN, HISYNSegment } from '../segments/HISYN.js'; import { BankAnswer } from '../bankAnswer.js'; @@ -21,6 +21,8 @@ import { HIKIMSegment, HIKIM } from '../segments/HIKIM.js'; import { HIUPDSegment, HIUPD } from '../segments/HIUPD.js'; import { HKTAB } from '../segments/HKTAB.js'; import { TanMediaInteraction } from './tanMediaInteraction.js'; +import { HKSPA } from '../segments/HKSPA.js'; +import { SepaAccountInteraction } from './sepaAccountInteraction.js'; export interface InitResponse extends ClientResponse { bankingInformation?: BankingInformation; @@ -171,7 +173,6 @@ export class InitDialogInteraction extends CustomerInteraction { const hiupds = response.findAllSegments(HIUPD.Id); const accounts: BankAccount[] = hiupds.map((upd) => { return { - isSepaAccount: false, accountNumber: upd.account.accountNumber, subAccountId: upd.account.subAccountId, bank: upd.account.bank, @@ -202,9 +203,24 @@ export class InitDialogInteraction extends CustomerInteraction { clientResponse.bankingInformation = this.config.bankingInformation; - if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { + if ( + this.config.selectedTanMethod && + this.config.selectedTanMethod.tanMediaRequirement > TanMediaRequirement.NotAllowed && + this.config.isTransactionSupported(HKTAB.Id) + ) { this.dialog!.addCustomerInteraction(new TanMediaInteraction(), true); } + + const bankAccounts = this.config.bankingInformation?.upd?.bankAccounts; + + if (bankAccounts) { + if ( + bankAccounts.some((account) => account.isSepaAccount === undefined) && + this.config.isTransactionSupported(HKSPA.Id) + ) { + this.dialog!.addCustomerInteraction(new SepaAccountInteraction(), true); + } + } } } diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts index 8d7636d..06e00ac 100644 --- a/src/interactions/sepaAccountInteraction.ts +++ b/src/interactions/sepaAccountInteraction.ts @@ -47,6 +47,10 @@ export class SepaAccountInteraction extends CustomerOrderInteraction { if (hispa) { clientResponse.sepaAccounts = hispa.sepaAccounts || []; + this.dialog!.config.bankingInformation.upd!.bankAccounts.forEach((bankAccount) => { + bankAccount.isSepaAccount = false; + }); + clientResponse.sepaAccounts.forEach((sepaAccount) => { const bankAccount = this.dialog!.config.getBankAccount(sepaAccount.accountNumber); if (bankAccount) {