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 154f17b..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, CustomerInteraction, CustomerOrderInteraction } from './interactions/customerInteraction.js'; -import { TanMediaInteraction, TanMediaResponse } from './interactions/tanMediaInteraction.js'; +import { ClientResponse, CustomerOrderInteraction } from './interactions/customerInteraction.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 {CreditCardStatementInteraction} from "./interactions/creditcardStatementInteraction.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 openCustomerInteractions = new Map(); + private currentDialog: Dialog | undefined; /** * Creates a new FinTS client @@ -59,27 +58,9 @@ export class FinTSClient { * @returns the synchronization response */ async synchronize(): Promise { - const dialog = new Dialog(this.config); - - const syncResponse = await this.initDialog(dialog, true); - - if (!syncResponse.success || syncResponse.requiresTan) { - return syncResponse; - } - - if (this.config.selectedTanMethod && this.config.isTransactionSupported(HKTAB.Id)) { - const tanMediaResponse = await dialog.startCustomerOrderInteraction(new TanMediaInteraction()); - - let tanMethod = this.config.selectedTanMethod; - if (tanMethod) { - tanMethod.activeTanMedia = tanMediaResponse.tanMediaList; - } - - syncResponse.bankAnswers.push(...tanMediaResponse.bankAnswers); - } - - await dialog.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,10 +179,10 @@ 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; } - /** * 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 @@ -216,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; } /** @@ -226,84 +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 dialog = new Dialog(this.config); - const syncResponse = await this.initDialog(dialog, false, interaction); - - if (!syncResponse.success || syncResponse.requiresTan) { - return syncResponse as TClientResponse; - } - - const clientResponse = await dialog.startCustomerOrderInteraction(interaction); - - if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, interaction); - } else { - await dialog.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 { - const interaction = this.openCustomerInteractions.get(tanReference); - - if (!interaction) { - throw new Error('No open customer interaction found for TAN reference: ' + tanReference); - } - - const dialog = interaction.dialog!; - let responseMessage = await dialog.sendTanMessage(interaction.segId, tanReference, tan); - let clientResponse = interaction.getClientResponse(responseMessage) as TClientResponse; - - this.openCustomerInteractions.delete(tanReference); - - if (!clientResponse.success) { - await dialog.end(); - return clientResponse; - } - - if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, interaction); - return clientResponse; - } - - const initDialogInteraction = interaction as InitDialogInteraction; - if (initDialogInteraction.followUpInteraction) { - clientResponse = await dialog.startCustomerOrderInteraction( - initDialogInteraction.followUpInteraction - ); - - if (clientResponse.requiresTan) { - this.openCustomerInteractions.set(clientResponse.tanReference!, initDialogInteraction.followUpInteraction); - return clientResponse; - } - } - - await dialog.end(); - return clientResponse; - } - - private async initDialog( - dialog: Dialog, - syncSystemId = false, - followUpInteraction?: CustomerOrderInteraction - ): Promise { - const interaction = new InitDialogInteraction(this.config, syncSystemId, followUpInteraction); - const initResponse = await dialog.initialize(interaction); - - if (initResponse.requiresTan) { - this.openCustomerInteractions.set(initResponse.tanReference!, interaction); + ): Promise> { + if (!this.currentDialog) { + throw new Error('no customer dialog was started which can continue'); } - return initResponse; + return await this.currentDialog.continue(tanReference, tan); } } diff --git a/src/dataGroups/SepaAccount.ts b/src/dataGroups/SepaAccount.ts new file mode 100644 index 0000000..5907703 --- /dev/null +++ b/src/dataGroups/SepaAccount.ts @@ -0,0 +1,32 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { DataGroup } from './DataGroup.js'; +import { Identification } from '../dataElements/Identification.js'; +import { BankIdentification } from './BankIdentification.js'; +import { Account } from './Account.js'; +import { YesNo } from '../dataElements/YesNo.js'; + +export type SepaAccount = Account & { + isSepaAccount?: boolean; + iban?: string; + bic?: string; +}; + +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/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/dialog.ts b/src/dialog.ts index 0364aef..c5338a5 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -3,102 +3,179 @@ 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'; 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'; +import { EndDialogInteraction } from './interactions/endDialogInteraction.js'; 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.interactions.push(new InitDialogInteraction(this.config, syncSystemId)); + this.interactions.push(new EndDialogInteraction()); + this.interactions.forEach((interaction) => { + interaction.dialog = this; + }); } - async initialize(interaction: InitDialogInteraction): Promise { + get currentInteraction(): CustomerInteraction { + return this.interactions[this.currentInteractionIndex]; + } + + 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.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.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 (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++; - const segments = interaction.getSegments(this.config); - segments.forEach((segment) => message.addSegment(segment)); + if (this.currentInteractionIndex > 0) { + this.isInitialized = true; + } + } + } while ( + !this.hasEnded && + this.currentInteractionIndex < this.interactions.length && + clientResponse.success && + !clientResponse.requiresTan + ); - 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, - }; + return this.responses; + } - message.addSegment(hktan); + async continue(tanOrderReference: string, tan?: string): Promise> { + if (!tanOrderReference) { + throw Error('tanOrderReference must be provided to continue a customer order with a TAN'); } - const initResponse = await this.httpClient.sendMessage(message); + if (!this.config.selectedTanMethod?.isDecoupled && !tan) { + throw Error('TAN must be provided for non-decoupled TAN methods'); + } - const clientResponse = interaction.getClientResponse(initResponse); - this.dialogId = clientResponse.dialogId; + if (this.hasEnded) { + throw Error('cannot continue a customer order when dialog has already ended'); + } - if (clientResponse.success) { - this.isInitialized = true; + if (!this.currentInteraction) { + throw new Error('there is no running customer interaction in this dialog to continue'); } - this.checkEnded(initResponse); + let clientResponse: ClientResponse; + + let isFirstMessage = true; - return clientResponse; + do { + const message = isFirstMessage + ? this.createCurrentTanMessage(tanOrderReference, tan) + : this.createCurrentCustomerMessage(); + const responseMessage = await this.httpClient.sendMessage(message); + 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; + } while ( + !this.hasEnded && + this.currentInteractionIndex < this.interactions.length && + clientResponse.success && + !clientResponse.requiresTan + ); + + return this.responses; } - async end(): Promise { - if (!this.isInitialized || this.hasEnded) { - return true; + addCustomerInteraction(interaction: CustomerInteraction, afterCurrent = false): void { + if (this.hasEnded) { + throw Error('cannot queue another customer interaction when dialog has already ended'); } - const tanMethod = this.config.selectedTanMethod; - const isScaSupported = tanMethod && tanMethod.version >= 6; + 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`); + } + + interaction.dialog = this; - if (this.config.tanMethodId && !tanMethod) { - throw new Error('given tanMethodId is not available according to the BPD'); + if (afterCurrent) { + this.interactions.splice(this.currentInteractionIndex + 1, 0, interaction); + return; } + this.interactions.splice(this.interactions.length - 1, 0, interaction); + } + + private createCurrentCustomerMessage(): CustomerMessage { this.lastMessageNumber++; - const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); + + 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 tanMethod = this.config.selectedTanMethod; + let isScaSupported = tanMethod && tanMethod.version >= 6; + let isTanMethodNeeded = isScaSupported; + + if (isCustomerOrder) { + const bankTransaction = this.config.bankingInformation.bpd?.allowedTransactions.find( + (t) => t.transId === this.currentInteraction.segId + ); + + isTanMethodNeeded = isScaSupported && bankTransaction?.tanRequired; + } if (this.config.userId && this.config.pin) { message.sign( @@ -111,46 +188,25 @@ export class Dialog { ); } - 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; - } + const segments = this.currentInteraction.getSegments(this.config); + segments.forEach((segment) => message.addSegment(segment)); - async startCustomerOrderInteraction( - interaction: CustomerOrderInteraction - ): Promise { - if (!this.isInitialized) { - throw Error('dialog must be initialized before sending a customer order'); - } + 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, + }; - if (this.hasEnded) { - throw Error('cannot send a customer message when dialog has already ended'); + message.addSegment(hktan); } - const bankTransaction = this.config.bankingInformation.bpd?.allowedTransactions.find( - (t) => t.transId === interaction.segId - ); - - if (!bankTransaction) { - throw Error(`transaction ${interaction.segId} is not supported according to the BPD`); - } + return message; + } - interaction.dialog = this; + private createCurrentTanMessage(tanOrderReference: string, tan?: string): CustomerMessage { this.lastMessageNumber++; - const message = new CustomerOrderMessage( - interaction.segId, - interaction.responseSegId, - this.dialogId, - this.lastMessageNumber - ); + const message = new CustomerMessage(this.dialogId, this.lastMessageNumber); if (this.config.userId && this.config.pin) { message.sign( @@ -158,37 +214,41 @@ export class Dialog { this.config.bankId, this.config.userId, this.config.pin, - this.config.bankingInformation.systemId, - this.config.selectedTanMethod && (bankTransaction.tanRequired || this.config.selectedTanMethod.version >= 6) - ? this.config.tanMethodId - : undefined + this.config.bankingInformation!.systemId, + this.config.tanMethodId, + tan ); } - const segments = interaction.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, - }; + 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: this.config.selectedTanMethod?.isDecoupled ? TanProcess.Status : TanProcess.Process2, + segId: this.currentInteraction.segId, + orderRef: tanOrderReference, + nextTan: false, + tanMedia: + this.config.selectedTanMethod!.tanMediaRequirement >= TanMediaRequirement.Optional + ? this.config.tanMediaName + : undefined, + }; - message.addSegment(hktan); - } + message.addSegment(hktan); } + return message; + } - let responseMessage = await this.httpClient.sendMessage(message); - + private async handlePartedMessages( + message: CustomerMessage, + responseMessage: Message, + interaction: CustomerInteraction + ) { let partedSegment = responseMessage.findSegment(PARTED.Id); if (partedSegment) { while (responseMessage.hasReturnCode(3040)) { const answers = responseMessage.getBankAnswers(); - const segmentWithContinuation = segments.find( + const segmentWithContinuation = message.segments.find( (s) => s.header.segId === interaction.segId ) as SegmentWithContinuationMark; if (!segmentWithContinuation) { @@ -215,69 +275,13 @@ export class Dialog { const index = responseMessage.segments.indexOf(partedSegment); responseMessage.segments.splice(index, 1, completeSegment); } - - this.checkEnded(responseMessage); - - 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'); - } - - 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 send a TAN message when dialog has alreay ended'); - } - - 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, - this.config.tanMethodId, - tan - ); - } - - 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: this.config.selectedTanMethod?.isDecoupled ? TanProcess.Status : TanProcess.Process2, - segId: refSegId, - orderRef: tanOrderReference, - nextTan: false, - tanMedia: - this.config.selectedTanMethod!.tanMediaRequirement >= TanMediaRequirement.Optional - ? this.config.tanMediaName - : undefined, - }; - - message.addSegment(hktan); - } - - const responseMessage = await this.httpClient.sendMessage(message); - - this.checkEnded(responseMessage); - - return responseMessage; } - 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 0b0a8ad..a67c37b 100644 --- a/src/interactions/customerInteraction.ts +++ b/src/interactions/customerInteraction.ts @@ -39,21 +39,26 @@ 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 { + handleClientResponse(message: Message): ClientResponse { const clientResponse = this.handleBaseResponse(message); + const currentBankingInformationSnapshot = JSON.stringify(this.dialog?.config.bankingInformation); + if (clientResponse.success && !clientResponse.requiresTan) { this.handleResponse(message, clientResponse); } - return clientResponse as TResponse; + clientResponse.bankingInformationUpdated = + currentBankingInformationSnapshot !== JSON.stringify(this.dialog?.config.bankingInformation); + + return clientResponse; } - protected abstract createSegments(init: FinTSConfig): Segment[]; + protected abstract createSegments(config: FinTSConfig): Segment[]; protected abstract handleResponse(response: Message, clientResponse: ClientResponse): void; private parseHHDUC(tanChallengeHHDUC: string): PhotoTan { 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 c6c1081..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'; @@ -19,17 +19,17 @@ 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'; +import { HKSPA } from '../segments/HKSPA.js'; +import { SepaAccountInteraction } from './sepaAccountInteraction.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); } @@ -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; @@ -163,7 +161,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 = @@ -205,8 +202,25 @@ export class InitDialogInteraction extends CustomerInteraction { this.config.bankingInformation.bankMessages = bankMessages; clientResponse.bankingInformation = this.config.bankingInformation; - clientResponse.bankingInformationUpdated = - currentBankingInformationSnapshot !== JSON.stringify(this.config.bankingInformation); + + 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 new file mode 100644 index 0000000..06e00ac --- /dev/null +++ b/src/interactions/sepaAccountInteraction.ts @@ -0,0 +1,64 @@ +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 { FinTSConfig } from '../config.js'; +import { SepaAccount } from '../dataGroups/SepaAccount.js'; + +export interface SepaAccountResponse extends ClientResponse { + sepaAccounts?: SepaAccount[]; +} + +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 || []; + + 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) { + bankAccount.isSepaAccount = sepaAccount.isSepaAccount; + bankAccount.iban = sepaAccount.iban; + bankAccount.bic = sepaAccount.bic; + } + }); + } + } +} 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/segments/HISPA.ts b/src/segments/HISPA.ts new file mode 100644 index 0000000..7e4d33b --- /dev/null +++ b/src/segments/HISPA.ts @@ -0,0 +1,20 @@ +import { SepaAccount, SepaAccountGroup } from '../dataGroups/SepaAccount.js'; +import { Segment } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; + +export type HISPASegment = Segment & { + sepaAccounts: SepaAccount[]; +}; + +/** + * 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 SepaAccountGroup('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); } 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); + }); }); 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' + ); + }); + }); +});