Compare commits

...

12 Commits

22 changed files with 257 additions and 74 deletions

View File

@ -9,11 +9,13 @@ import net.dankito.banking.client.model.response.TransferMoneyResponse
import net.codinux.banking.fints.callback.FinTsClientCallback import net.codinux.banking.fints.callback.FinTsClientCallback
import net.codinux.banking.fints.config.FinTsClientConfiguration import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.mapper.FinTsModelMapper import net.codinux.banking.fints.mapper.FinTsModelMapper
import net.codinux.banking.fints.messages.datenelemente.implementierte.KundensystemID
import net.codinux.banking.fints.model.* import net.codinux.banking.fints.model.*
import net.codinux.banking.fints.response.client.FinTsClientResponse import net.codinux.banking.fints.response.client.FinTsClientResponse
import net.codinux.banking.fints.response.client.GetAccountInfoResponse import net.codinux.banking.fints.response.client.GetAccountInfoResponse
import net.codinux.banking.fints.response.client.GetAccountTransactionsResponse import net.codinux.banking.fints.response.client.GetAccountTransactionsResponse
import net.codinux.banking.fints.response.segments.AccountType import net.codinux.banking.fints.response.segments.AccountType
import net.codinux.banking.fints.response.segments.BankParameters
import net.codinux.banking.fints.util.BicFinder import net.codinux.banking.fints.util.BicFinder
@ -40,50 +42,39 @@ open class FinTsClient(
} }
open suspend fun getAccountDataAsync(param: GetAccountDataParameter): GetAccountDataResponse { open suspend fun getAccountDataAsync(param: GetAccountDataParameter): GetAccountDataResponse {
val finTsServerAddress = config.finTsServerAddressFinder.findFinTsServerAddress(param.bankCode) val basicAccountDataResponse = getRequiredDataToSendUserJobs(param)
if (finTsServerAddress.isNullOrBlank()) {
return GetAccountDataResponse(ErrorCode.BankDoesNotSupportFinTs3, "Either bank does not support FinTS 3.0 or we don't know its FinTS server address", null, listOf())
}
val bank = mapper.mapToBankData(param, finTsServerAddress) if (basicAccountDataResponse.successful == false || param.retrieveOnlyAccountInfo || basicAccountDataResponse.finTsModel == null) {
val accounts = param.accounts return GetAccountDataResponse(basicAccountDataResponse.error, basicAccountDataResponse.errorMessage, null,
basicAccountDataResponse.messageLogWithoutSensitiveData, basicAccountDataResponse.finTsModel)
if (accounts.isNullOrEmpty() || param.retrieveOnlyAccountInfo) { // then first retrieve customer's bank accounts
val getAccountInfoResponse = getAccountInfo(param, bank)
if (getAccountInfoResponse.successful == false || param.retrieveOnlyAccountInfo) {
return GetAccountDataResponse(mapper.mapErrorCode(getAccountInfoResponse), mapper.mapErrorMessages(getAccountInfoResponse), null,
getAccountInfoResponse.messageLog, bank)
} else {
return getAccountData(param, getAccountInfoResponse.bank, getAccountInfoResponse.bank.accounts, getAccountInfoResponse)
}
} else { } else {
return getAccountData(param, bank, accounts.map { mapper.mapToAccountData(it, param) }, null) val bank = basicAccountDataResponse.finTsModel!!
return getAccountData(param, bank, bank.accounts, basicAccountDataResponse.messageLogWithoutSensitiveData)
} }
} }
protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, accounts: List<AccountData>, previousJobResponse: FinTsClientResponse?): GetAccountDataResponse { protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, accounts: List<AccountData>, previousJobMessageLog: List<MessageLogEntry>?): GetAccountDataResponse {
val retrievedTransactionsResponses = mutableListOf<GetAccountTransactionsResponse>() val retrievedTransactionsResponses = mutableListOf<GetAccountTransactionsResponse>()
val accountsSupportingRetrievingTransactions = accounts.filter { it.supportsRetrievingBalance || it.supportsRetrievingAccountTransactions } val accountsSupportingRetrievingTransactions = accounts.filter { it.supportsRetrievingBalance || it.supportsRetrievingAccountTransactions }
if (accountsSupportingRetrievingTransactions.isEmpty()) { if (accountsSupportingRetrievingTransactions.isEmpty()) {
val errorMessage = "None of the accounts ${accounts.map { it.productName }} supports retrieving balance or transactions" // TODO: translate val errorMessage = "None of the accounts ${accounts.map { it.productName }} supports retrieving balance or transactions" // TODO: translate
return GetAccountDataResponse(ErrorCode.NoneOfTheAccountsSupportsRetrievingData, errorMessage, mapper.map(bank), previousJobResponse?.messageLog ?: listOf(), bank) return GetAccountDataResponse(ErrorCode.NoneOfTheAccountsSupportsRetrievingData, errorMessage, mapper.map(bank), previousJobMessageLog ?: listOf(), bank)
} }
accountsSupportingRetrievingTransactions.forEach { account -> accountsSupportingRetrievingTransactions.forEach { account ->
retrievedTransactionsResponses.add(getAccountData(param, bank, account)) retrievedTransactionsResponses.add(getAccountTransactions(param, bank, account))
} }
val unsuccessfulJob = retrievedTransactionsResponses.firstOrNull { it.successful == false } val unsuccessfulJob = retrievedTransactionsResponses.firstOrNull { it.successful == false }
val errorCode = unsuccessfulJob?.let { mapper.mapErrorCode(it) } val errorCode = unsuccessfulJob?.let { mapper.mapErrorCode(it) }
?: if (retrievedTransactionsResponses.size < accountsSupportingRetrievingTransactions.size) ErrorCode.DidNotRetrieveAllAccountData else null ?: if (retrievedTransactionsResponses.size < accountsSupportingRetrievingTransactions.size) ErrorCode.DidNotRetrieveAllAccountData else null
return GetAccountDataResponse(errorCode, mapper.mapErrorMessages(unsuccessfulJob), mapper.map(bank, retrievedTransactionsResponses), return GetAccountDataResponse(errorCode, mapper.mapErrorMessages(unsuccessfulJob), mapper.map(bank, retrievedTransactionsResponses, param.retrieveTransactionsTo),
mapper.mergeMessageLog(previousJobResponse, *retrievedTransactionsResponses.toTypedArray()), bank) mapper.mergeMessageLog(previousJobMessageLog, *retrievedTransactionsResponses.map { it.messageLog }.toTypedArray()), bank)
} }
protected open suspend fun getAccountData(param: GetAccountDataParameter, bank: BankData, account: AccountData): GetAccountTransactionsResponse { protected open suspend fun getAccountTransactions(param: GetAccountDataParameter, bank: BankData, account: AccountData): GetAccountTransactionsResponse {
val context = JobContext(JobContextType.GetTransactions, this.callback, config, bank, account) val context = JobContext(JobContextType.GetTransactions, this.callback, config, bank, account)
return config.jobExecutor.getTransactionsAsync(context, mapper.toGetAccountTransactionsParameter(param, bank, account)) return config.jobExecutor.getTransactionsAsync(context, mapper.toGetAccountTransactionsParameter(param, bank, account))
@ -163,6 +154,44 @@ open class FinTsClient(
return null return null
} }
/**
* Ensures all basic data to initialize a dialog with strong customer authorization is retrieved so you can send your
* actual jobs (Geschäftsvorfälle) to your bank's FinTS server.
*
* These data include:
* - Bank communication data like FinTS server address, BIC, bank name, bank code used for FinTS.
* - BPD (BankParameterDaten): bank name, BPD version, supported languages, supported HBCI versions, supported TAN methods,
* max count jobs per message (Anzahl Geschäftsvorfallsarten) (see [BankParameters] [BankParameters](src/commonMain/kotlin/net/codinux/banking/fints/response/segmentsBankParameters) ).
* - Min and max online banking password length, min TAN length, hint for login name (for all: if available)
* - UPD (UserParameterDaten): username, UPD version.
* - Customer system ID (Kundensystem-ID, see [KundensystemID]), TAN methods available for user and may user's TAN media.
* - Which jobs the bank supports and which jobs need strong customer authorization (= require HKTAN segment).
* - Which jobs the user is allowed to use.
* - Which jobs can be called for a specific bank account.
*
* When implementing your own jobs, call this method first, then send an init dialog message and in next message your actual jobs.
*
* More or less implements everything of 02 FinTS_3.0_Formals.pdf so that you can start directly with the jobs from
* 04 FinTS_3.0_Messages_Geschaeftsvorfaelle.pdf
*/
open suspend fun getRequiredDataToSendUserJobs(param: FinTsClientParameter): net.dankito.banking.client.model.response.FinTsClientResponse {
if (param.finTsModel != null) {
return net.dankito.banking.client.model.response.FinTsClientResponse(null, null, emptyList(), param.finTsModel)
}
val finTsServerAddress = config.finTsServerAddressFinder.findFinTsServerAddress(param.bankCode)
if (finTsServerAddress.isNullOrBlank()) {
return net.dankito.banking.client.model.response.FinTsClientResponse(ErrorCode.BankDoesNotSupportFinTs3, "Either bank does not support FinTS 3.0 or we don't know its FinTS server address", emptyList(), null)
}
val bank = mapper.mapToBankData(param, finTsServerAddress)
val getAccountInfoResponse = getAccountInfo(param, bank)
return net.dankito.banking.client.model.response.FinTsClientResponse(mapper.mapErrorCode(getAccountInfoResponse), mapper.mapErrorMessages(getAccountInfoResponse),
getAccountInfoResponse.messageLog, bank)
}
protected open suspend fun getAccountInfo(param: FinTsClientParameter, bank: BankData): GetAccountInfoResponse { protected open suspend fun getAccountInfo(param: FinTsClientParameter, bank: BankData): GetAccountInfoResponse {
param.finTsModel?.let { param.finTsModel?.let {
// TODO: implement // TODO: implement

View File

@ -1,6 +1,7 @@
package net.codinux.banking.fints package net.codinux.banking.fints
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.log.logger import net.codinux.log.logger
import net.codinux.banking.fints.messages.MessageBuilder import net.codinux.banking.fints.messages.MessageBuilder
@ -21,6 +22,8 @@ import net.codinux.banking.fints.util.TanMethodSelector
import net.codinux.banking.fints.extensions.minusDays import net.codinux.banking.fints.extensions.minusDays
import net.codinux.banking.fints.extensions.todayAtEuropeBerlin import net.codinux.banking.fints.extensions.todayAtEuropeBerlin
import net.codinux.banking.fints.extensions.todayAtSystemDefaultTimeZone import net.codinux.banking.fints.extensions.todayAtSystemDefaultTimeZone
import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
/** /**
@ -228,6 +231,8 @@ open class FinTsJobExecutor(
} }
} }
val startTime = Clock.System.now()
val response = getAndHandleResponseForMessage(context, message) val response = getAndHandleResponseForMessage(context, message)
closeDialog(context) closeDialog(context)
@ -237,7 +242,7 @@ open class FinTsJobExecutor(
val fromDate = parameter.fromDate val fromDate = parameter.fromDate
?: parameter.account.countDaysForWhichTransactionsAreKept?.let { LocalDate.todayAtSystemDefaultTimeZone().minusDays(it) } ?: parameter.account.countDaysForWhichTransactionsAreKept?.let { LocalDate.todayAtSystemDefaultTimeZone().minusDays(it) }
?: bookedTransactions.minByOrNull { it.valueDate }?.valueDate ?: bookedTransactions.minByOrNull { it.valueDate }?.valueDate
val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError) val retrievedData = RetrievedAccountData(parameter.account, successful, balance, bookedTransactions, unbookedTransactions, startTime, fromDate, parameter.toDate ?: LocalDate.todayAtEuropeBerlin(), response.internalError)
return GetAccountTransactionsResponse(context, response, retrievedData, return GetAccountTransactionsResponse(context, response, retrievedData,
if (parameter.maxCountEntries != null) parameter.isSettingMaxCountEntriesAllowedByBank else null) if (parameter.maxCountEntries != null) parameter.isSettingMaxCountEntriesAllowedByBank else null)
@ -413,16 +418,61 @@ open class FinTsJobExecutor(
} }
} }
protected open fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) { protected open suspend fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) {
context.bank.selectedTanMethod.decoupledParameters?.let { decoupledTanMethodParameters -> context.bank.selectedTanMethod.decoupledParameters?.let { decoupledTanMethodParameters ->
if (tanResponse.tanProcess == TanProcess.AppTan && decoupledTanMethodParameters.periodicStateRequestsAllowed) { if (decoupledTanMethodParameters.periodicStateRequestsAllowed) {
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge) val responseAfterApprovingDecoupledTan =
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse, decoupledTanMethodParameters)
if (responseAfterApprovingDecoupledTan != null) {
tanChallenge.userApprovedDecoupledTan(responseAfterApprovingDecoupledTan)
} else {
tanChallenge.userDidNotEnterTan()
}
} }
} }
} }
protected open fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge) { protected open suspend fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse, parameters: DecoupledTanMethodParameters): BankResponse? {
log.info { "automaticallyRetrieveIfUserEnteredDecoupledTan() called for $tanChallenge" } log.info { "automaticallyRetrieveIfUserEnteredDecoupledTan() called for $tanChallenge" }
delay(max(5, parameters.initialDelayInSecondsForStateRequest).seconds)
var iteration = 0
val minWaitTime = when {
parameters.maxNumberOfStateRequests <= 10 -> 30
parameters.maxNumberOfStateRequests <= 24 -> 10
else -> 3
}
val delayForNextStateRequest = max(minWaitTime, parameters.delayInSecondsForNextStateRequest).seconds
while (iteration < parameters.maxNumberOfStateRequests) {
try {
val message = messageBuilder.createDecoupledTanStatusMessage(context, tanResponse)
val response = getAndHandleResponseForMessage(context, message)
val tanFeedbacks = response.segmentFeedbacks.filter { it.referenceSegmentNumber == MessageBuilder.SignedMessagePayloadFirstSegmentNumber }
if (tanFeedbacks.isNotEmpty()) {
// new feedback code for Decoupled TAN: 0900 Sicherheitsfreigabe gültig
// Sparkasse responds for pushTan with: HIRMS:4:2:3+0020::Der Auftrag wurde ausgeführt.+0020::Die gebuchten Umsätze wurden übermittelt.'
val isTanApproved = tanFeedbacks.any { it.feedbacks.any { it.responseCode == 900 || it.responseCode == 20 } }
if (isTanApproved) {
return response
}
}
iteration++
// sometimes delayInSecondsForNextStateRequests is only 1 or 2 seconds, that's too fast i think
delay(delayForNextStateRequest)
} catch (e: Throwable) {
log.error(e) { "Could not check status of Decoupled TAN" }
return null
}
}
return null
} }
protected open suspend fun handleEnterTanResult(context: JobContext, enteredTanResult: EnterTanResult, tanResponse: TanResponse, protected open suspend fun handleEnterTanResult(context: JobContext, enteredTanResult: EnterTanResult, tanResponse: TanResponse,
@ -430,19 +480,18 @@ open class FinTsJobExecutor(
if (enteredTanResult.changeTanMethodTo != null) { if (enteredTanResult.changeTanMethodTo != null) {
return handleUserAsksToChangeTanMethodAndResendLastMessage(context, enteredTanResult.changeTanMethodTo) return handleUserAsksToChangeTanMethodAndResendLastMessage(context, enteredTanResult.changeTanMethodTo)
} } else if (enteredTanResult.changeTanMediumTo is TanGeneratorTanMedium) {
else if (enteredTanResult.changeTanMediumTo is TanGeneratorTanMedium) {
return handleUserAsksToChangeTanMediumAndResendLastMessage(context, enteredTanResult.changeTanMediumTo, return handleUserAsksToChangeTanMediumAndResendLastMessage(context, enteredTanResult.changeTanMediumTo,
enteredTanResult.changeTanMediumResultCallback) enteredTanResult.changeTanMediumResultCallback)
} } else if (enteredTanResult.userApprovedDecoupledTan == true && enteredTanResult.responseAfterApprovingDecoupledTan != null) {
else if (enteredTanResult.enteredTan == null) { return enteredTanResult.responseAfterApprovingDecoupledTan
} else if (enteredTanResult.enteredTan == null) {
// i tried to send a HKTAN with cancelJob = true but then i saw there are no tan methods that support cancellation (at least not at my bank) // i tried to send a HKTAN with cancelJob = true but then i saw there are no tan methods that support cancellation (at least not at my bank)
// but it's not required anyway, tan times out after some time. Simply don't respond anything and close dialog // but it's not required anyway, tan times out after some time. Simply don't respond anything and close dialog
response.tanRequiredButUserDidNotEnterOne = true response.tanRequiredButUserDidNotEnterOne = true
return response return response
} } else {
else {
return sendTanToBank(context, enteredTanResult.enteredTan, tanResponse) return sendTanToBank(context, enteredTanResult.enteredTan, tanResponse)
} }
} }

View File

@ -12,7 +12,7 @@ fun LocalDate.Companion.todayAtSystemDefaultTimeZone(): LocalDate {
} }
fun LocalDate.Companion.todayAtEuropeBerlin(): LocalDate { fun LocalDate.Companion.todayAtEuropeBerlin(): LocalDate {
return nowAt(TimeZone.europeBerlin) return nowAt(TimeZone.EuropeBerlin)
} }
@JsName("nowAtForDate") @JsName("nowAtForDate")

View File

@ -9,7 +9,7 @@ fun LocalDateTime.Companion.nowAtUtc(): LocalDateTime {
} }
fun LocalDateTime.Companion.nowAtEuropeBerlin(): LocalDateTime { fun LocalDateTime.Companion.nowAtEuropeBerlin(): LocalDateTime {
return nowAt(TimeZone.europeBerlin) return nowAt(TimeZone.EuropeBerlin)
} }
fun LocalDateTime.Companion.nowAt(timeZone: TimeZone): LocalDateTime { fun LocalDateTime.Companion.nowAt(timeZone: TimeZone): LocalDateTime {

View File

@ -3,5 +3,5 @@ package net.codinux.banking.fints.extensions
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
val TimeZone.Companion.europeBerlin: TimeZone val TimeZone.Companion.EuropeBerlin: TimeZone
get() = TimeZone.of("Europe/Berlin") get() = TimeZone.of("Europe/Berlin")

View File

@ -1,6 +1,10 @@
package net.codinux.banking.fints.mapper package net.codinux.banking.fints.mapper
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import net.codinux.banking.fints.extensions.EuropeBerlin
import net.dankito.banking.client.model.* import net.dankito.banking.client.model.*
import net.dankito.banking.client.model.AccountTransaction import net.dankito.banking.client.model.AccountTransaction
import net.dankito.banking.client.model.parameter.FinTsClientParameter import net.dankito.banking.client.model.parameter.FinTsClientParameter
@ -70,16 +74,32 @@ open class FinTsModelMapper {
} }
} }
open fun map(bank: BankData, retrievedTransactionsResponses: List<GetAccountTransactionsResponse>): CustomerAccount { open fun map(bank: BankData, retrievedTransactionsResponses: List<GetAccountTransactionsResponse>, retrieveTransactionsTo: LocalDate? = null): CustomerAccount {
val customerAccount = map(bank) val customerAccount = map(bank)
val retrievedData = retrievedTransactionsResponses.mapNotNull { it.retrievedData } val retrievedData = retrievedTransactionsResponses.mapNotNull { it.retrievedData }
customerAccount.accounts.forEach { bankAccount -> customerAccount.accounts.forEach { bankAccount ->
retrievedData.firstOrNull { it.account.accountIdentifier == bankAccount.identifier }?.let { accountTransactionsResponse -> retrievedData.firstOrNull { it.account.accountIdentifier == bankAccount.identifier }?.let { accountTransactionsResponse ->
bankAccount.balance = accountTransactionsResponse.balance ?: Money.Zero accountTransactionsResponse.balance?.let { balance ->
bankAccount.retrievedTransactionsFrom = accountTransactionsResponse.retrievedTransactionsFrom bankAccount.balance = balance
bankAccount.retrievedTransactionsTo = accountTransactionsResponse.retrievedTransactionsTo }
bankAccount.bookedTransactions = map(accountTransactionsResponse)
if (accountTransactionsResponse.retrievedTransactionsFrom != null && (bankAccount.retrievedTransactionsFrom == null ||
accountTransactionsResponse.retrievedTransactionsFrom!! < bankAccount.retrievedTransactionsFrom!!)) {
bankAccount.retrievedTransactionsFrom = accountTransactionsResponse.retrievedTransactionsFrom
}
val retrievalTime = if (retrieveTransactionsTo == null) accountTransactionsResponse.retrievalTime
else retrieveTransactionsTo.atTime(0, 0).toInstant(TimeZone.EuropeBerlin)
if (bankAccount.lastTransactionsRetrievalTime == null || bankAccount.lastTransactionsRetrievalTime!! <= retrievalTime) { // if retrieveTransactionsTo is set it may is older than current account's lastTransactionsRetrievalTime
bankAccount.lastTransactionsRetrievalTime = retrievalTime
}
if (accountTransactionsResponse.bookedTransactions.isNotEmpty()) {
bankAccount.bookedTransactions = bankAccount.bookedTransactions.toMutableList().apply {
addAll(map(accountTransactionsResponse))
}
}
} }
} }
@ -157,6 +177,10 @@ open class FinTsModelMapper {
else errorMessages.joinToString("\r\n") else errorMessages.joinToString("\r\n")
} }
open fun mergeMessageLog(vararg messageLogs: List<MessageLogEntry>?): List<MessageLogEntry> {
return messageLogs.filterNotNull().flatten()
}
open fun mergeMessageLog(vararg responses: FinTsClientResponse?): List<MessageLogEntry> { open fun mergeMessageLog(vararg responses: FinTsClientResponse?): List<MessageLogEntry> {
return responses.filterNotNull().flatMap { it.messageLog } return responses.filterNotNull().flatMap { it.messageLog }
} }

View File

@ -41,7 +41,7 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
private const val SignatureHeaderSegmentNumber = MessageHeaderSegmentNumber + 1 private const val SignatureHeaderSegmentNumber = MessageHeaderSegmentNumber + 1
private const val SignedMessagePayloadFirstSegmentNumber = SignatureHeaderSegmentNumber + 1 const val SignedMessagePayloadFirstSegmentNumber = SignatureHeaderSegmentNumber + 1
} }
@ -295,12 +295,22 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
val segments = listOf( val segments = listOf(
ZweiSchrittTanEinreichung(SignedMessagePayloadFirstSegmentNumber, tanProcess, null, ZweiSchrittTanEinreichung(SignedMessagePayloadFirstSegmentNumber, tanProcess, null,
tanResponse.jobHashValue, tanResponse.jobReference, false, null, tanResponse.tanMediaIdentifier) tanResponse.jobHashValue, tanResponse.jobReference, false, null, tanResponse.tanMediaIdentifier, tanResponse.segmentVersion)
) )
return createSignedMessageBuilderResult(context, MessageType.Tan, createSignedMessage(context, enteredTan, segments), segments) return createSignedMessageBuilderResult(context, MessageType.Tan, createSignedMessage(context, enteredTan, segments), segments)
} }
open fun createDecoupledTanStatusMessage(context: JobContext, tanResponse: TanResponse): MessageBuilderResult {
val segments = listOf(
ZweiSchrittTanEinreichung(SignedMessagePayloadFirstSegmentNumber, TanProcess.AppTan,
jobReference = tanResponse.jobReference, furtherTanFollows = false, segmentVersion = 7, tanMediaIdentifier = tanResponse.tanMediaIdentifier)
)
return createSignedMessageBuilderResult(context, MessageType.CheckDecoupledTanStatus, createSignedMessage(context, null, segments), segments)
}
open fun createBankTransferMessage(context: JobContext, data: BankTransferData, account: AccountData): MessageBuilderResult { open fun createBankTransferMessage(context: JobContext, data: BankTransferData, account: AccountData): MessageBuilderResult {
@ -521,8 +531,10 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
} }
protected open fun createTwoStepTanSegment(context: JobContext, segmentId: CustomerSegmentId, segmentNumber: Int): ZweiSchrittTanEinreichung { protected open fun createTwoStepTanSegment(context: JobContext, segmentId: CustomerSegmentId, segmentNumber: Int): ZweiSchrittTanEinreichung {
val bank = context.bank
return ZweiSchrittTanEinreichung(segmentNumber, TanProcess.TanProcess4, segmentId, return ZweiSchrittTanEinreichung(segmentNumber, TanProcess.TanProcess4, segmentId,
tanMediaIdentifier = getTanMediaIdentifierIfRequired(context)) tanMediaIdentifier = getTanMediaIdentifierIfRequired(context), segmentVersion = bank.selectedTanMethod.hktanVersion)
} }
protected open fun getTanMediaIdentifierIfRequired(context: JobContext): String? { protected open fun getTanMediaIdentifierIfRequired(context: JobContext): String? {

View File

@ -50,6 +50,11 @@ enum class TanProcess(override val code: String) : ICodeEnum {
*/ */
TanProcess4("4"), TanProcess4("4"),
AppTan("S") // TODO: what is this? /**
* kann nur nach dem ersten Schritt auftreten. Er dient im DecoupledVerfahren der Statusabfrage der vom Kunden zu
* tätigenden Sicherheitsfreigabe auf einem anderen Gerät mittels HKTAN. Dieser Geschäftsvorfall wird mit HITAN,
* TAN-Prozess=S beantwortet.
*/
AppTan("S")
} }

View File

@ -19,16 +19,17 @@ open class ZweiSchrittTanEinreichung(
jobReference: String? = null, jobReference: String? = null,
furtherTanFollows: Boolean? = false, furtherTanFollows: Boolean? = false,
cancelJob: Boolean? = null, cancelJob: Boolean? = null,
tanMediaIdentifier: String? = null tanMediaIdentifier: String? = null,
segmentVersion: Int = 6
) : Segment(listOf( ) : Segment(listOf(
Segmentkopf(CustomerSegmentId.Tan, 6, segmentNumber), Segmentkopf(CustomerSegmentId.Tan, segmentVersion, segmentNumber),
TANProzessDatenelement(process), TANProzessDatenelement(process),
Segmentkennung(segmentIdForWhichTanShouldGetGenerated?.id ?: ""), // M: bei TAN-Prozess=1. M: bei TAN-Prozess=4 und starker Authentifizierung. N: sonst Segmentkennung(segmentIdForWhichTanShouldGetGenerated?.id ?: ""), // M: bei TAN-Prozess=1. M: bei TAN-Prozess=4 und starker Authentifizierung. N: sonst
NotAllowedDatenelement(), // Kontoverbindung // M: bei TAN-Prozess=1 und "Auftraggeberkonto erforderlich"=2 und Kontoverbindung im Auftrag enthalten. N: sonst NotAllowedDatenelement(), // Kontoverbindung // M: bei TAN-Prozess=1 und "Auftraggeberkonto erforderlich"=2 und Kontoverbindung im Auftrag enthalten. N: sonst
AuftragsHashwert(jobHashValue ?: "", Existenzstatus.NotAllowed), // M: bei AuftragsHashwertverfahren<>0 und TAN-Prozess=1. N: sonst AuftragsHashwert(jobHashValue ?: "", Existenzstatus.NotAllowed), // M: bei AuftragsHashwertverfahren<>0 und TAN-Prozess=1. N: sonst
Auftragsreferenz(jobReference ?: "", Existenzstatus.Mandatory), // M: bei TAN-Prozess=2, 3, 4. O: bei TAN-Prozess=1 Auftragsreferenz(jobReference ?: "", if (process == TanProcess.TanProcess2 || process == TanProcess.TanProcess3 || process == TanProcess.AppTan) Existenzstatus.Mandatory else Existenzstatus.Optional), // M: bei TAN-Prozess=2, 3, 4. O: bei TAN-Prozess=1
JaNein(furtherTanFollows, if (process == TanProcess.TanProcess1 || process == TanProcess.TanProcess2) Existenzstatus.Mandatory else Existenzstatus.NotAllowed), // M: bei TAN-Prozess=1, 2. N: bei TAN-Prozess=3, 4 JaNein(furtherTanFollows, if (process == TanProcess.TanProcess1 || process == TanProcess.TanProcess2 || process == TanProcess.AppTan) Existenzstatus.Mandatory else Existenzstatus.NotAllowed), // M: bei TAN-Prozess=1, 2. N: bei TAN-Prozess=3, 4
JaNein(cancelJob, if (process == TanProcess.TanProcess2 && cancelJob != null) Existenzstatus.Optional else Existenzstatus.NotAllowed), // O: bei TAN-Prozess=2 und „Auftragsstorno erlaubt“=J. N: sonst JaNein(cancelJob, if (process == TanProcess.TanProcess2 && cancelJob != null) Existenzstatus.Optional else Existenzstatus.NotAllowed), // O: bei TAN-Prozess=2 und „Auftragsstorno erlaubt“=J. N: sonst
NotAllowedDatenelement(), // TODO: SMS-Abbuchungskonto // M: Bei TAN-Process=1, 3, 4 und „SMS-Abbuchungskonto erforderlich“=2. O: sonst NotAllowedDatenelement(), // TODO: SMS-Abbuchungskonto // M: Bei TAN-Process=1, 3, 4 und „SMS-Abbuchungskonto erforderlich“=2. O: sonst
NotAllowedDatenelement(), // TODO: Challenge-Klasse // M: bei TAN-Prozess=1 und „Challenge-Klasse erforderlich“=J. N: sonst NotAllowedDatenelement(), // TODO: Challenge-Klasse // M: bei TAN-Prozess=1 und „Challenge-Klasse erforderlich“=J. N: sonst

View File

@ -8,12 +8,12 @@ open class DecoupledTanMethodParameters(
open val manualConfirmationAllowed: Boolean, open val manualConfirmationAllowed: Boolean,
open val periodicStateRequestsAllowed: Boolean, open val periodicStateRequestsAllowed: Boolean,
open val maxNumberOfStateRequests: Int, open val maxNumberOfStateRequests: Int,
open val initialDelayInSecondsForStateRequests: Int, open val initialDelayInSecondsForStateRequest: Int,
open val delayInSecondsForNextStateRequests: Int open val delayInSecondsForNextStateRequest: Int
) { ) {
override fun toString(): String { override fun toString(): String {
return "DecoupledTanMethodParameters(manualConfirmationAllowed=$manualConfirmationAllowed, periodicStateRequestsAllowed=$periodicStateRequestsAllowed, maxNumberOfStateRequests=$maxNumberOfStateRequests, initialDelayInSecondsForStateRequests=$initialDelayInSecondsForStateRequests, delayInSecondsForNextStateRequests=$delayInSecondsForNextStateRequests)" return "DecoupledTanMethodParameters(manualConfirmationAllowed=$manualConfirmationAllowed, periodicStateRequestsAllowed=$periodicStateRequestsAllowed, maxNumberOfStateRequests=$maxNumberOfStateRequests, initialDelayInSecondsForStateRequests=$initialDelayInSecondsForStateRequest, delayInSecondsForNextStateRequests=$delayInSecondsForNextStateRequest)"
} }
} }

View File

@ -1,17 +1,24 @@
package net.codinux.banking.fints.model package net.codinux.banking.fints.model
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.response.BankResponse
import net.codinux.banking.fints.response.client.FinTsClientResponse import net.codinux.banking.fints.response.client.FinTsClientResponse
open class EnterTanResult( open class EnterTanResult(
val enteredTan: String?, val enteredTan: String?,
val userApprovedDecoupledTan: Boolean? = null,
val responseAfterApprovingDecoupledTan: BankResponse? = null,
val changeTanMethodTo: TanMethod? = null, val changeTanMethodTo: TanMethod? = null,
val changeTanMediumTo: TanMedium? = null, val changeTanMediumTo: TanMedium? = null,
val changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)? = null val changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)? = null
) { ) {
override fun toString(): String { override fun toString(): String {
if (userApprovedDecoupledTan == true) {
return "User approved Decoupled TAN"
}
if (changeTanMethodTo != null) { if (changeTanMethodTo != null) {
return "User asks to change TAN method to $changeTanMethodTo" return "User asks to change TAN method to $changeTanMethodTo"
} }

View File

@ -15,6 +15,8 @@ enum class MessageType {
SynchronizeCustomerSystemId, SynchronizeCustomerSystemId,
CheckDecoupledTanStatus,
Tan, Tan,
GetBalance, GetBalance,

View File

@ -1,5 +1,6 @@
package net.codinux.banking.fints.model package net.codinux.banking.fints.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@ -9,6 +10,7 @@ open class RetrievedAccountData(
open val balance: Money?, open val balance: Money?,
open var bookedTransactions: Collection<AccountTransaction>, open var bookedTransactions: Collection<AccountTransaction>,
open var unbookedTransactions: Collection<Any>, open var unbookedTransactions: Collection<Any>,
open val retrievalTime: Instant,
open val retrievedTransactionsFrom: LocalDate?, open val retrievedTransactionsFrom: LocalDate?,
open val retrievedTransactionsTo: LocalDate?, open val retrievedTransactionsTo: LocalDate?,
open val errorMessage: String? = null open val errorMessage: String? = null
@ -17,7 +19,7 @@ open class RetrievedAccountData(
companion object { companion object {
fun unsuccessful(account: AccountData): RetrievedAccountData { fun unsuccessful(account: AccountData): RetrievedAccountData {
return RetrievedAccountData(account, false, null, listOf(), listOf(), null, null) return RetrievedAccountData(account, false, null, listOf(), listOf(), Instant.DISTANT_PAST, null, null)
} }
} }

View File

@ -1,6 +1,7 @@
package net.codinux.banking.fints.model package net.codinux.banking.fints.model
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.response.BankResponse
import net.codinux.banking.fints.response.client.FinTsClientResponse import net.codinux.banking.fints.response.client.FinTsClientResponse
@ -20,21 +21,39 @@ open class TanChallenge(
open val isEnteringTanDone: Boolean open val isEnteringTanDone: Boolean
get() = enterTanResult != null get() = enterTanResult != null
private val userApprovedDecoupledTanCallbacks = mutableListOf<() -> Unit>()
fun userEnteredTan(enteredTan: String) { fun userEnteredTan(enteredTan: String) {
this.enterTanResult = EnterTanResult(enteredTan.replace(" ", "")) this.enterTanResult = EnterTanResult(enteredTan.replace(" ", ""))
} }
internal fun userApprovedDecoupledTan(responseAfterApprovingDecoupledTan: BankResponse) {
this.enterTanResult = EnterTanResult(null, true, responseAfterApprovingDecoupledTan)
userApprovedDecoupledTanCallbacks.forEach { it.invoke() }
userApprovedDecoupledTanCallbacks.clear()
}
fun userDidNotEnterTan() { fun userDidNotEnterTan() {
this.enterTanResult = EnterTanResult(null) this.enterTanResult = EnterTanResult(null)
} }
fun userAsksToChangeTanMethod(changeTanMethodTo: TanMethod) { fun userAsksToChangeTanMethod(changeTanMethodTo: TanMethod) {
this.enterTanResult = EnterTanResult(null, changeTanMethodTo) this.enterTanResult = EnterTanResult(null, changeTanMethodTo = changeTanMethodTo)
} }
fun userAsksToChangeTanMedium(changeTanMediumTo: TanMedium, changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?) { fun userAsksToChangeTanMedium(changeTanMediumTo: TanMedium, changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?) {
this.enterTanResult = EnterTanResult(null, null, changeTanMediumTo, changeTanMediumResultCallback) this.enterTanResult = EnterTanResult(null, changeTanMediumTo = changeTanMediumTo, changeTanMediumResultCallback = changeTanMediumResultCallback)
}
fun addUserApprovedDecoupledTanCallback(callback: () -> Unit) {
if (isEnteringTanDone == false) {
this.userApprovedDecoupledTanCallbacks.add(callback)
} else if (enterTanResult != null && enterTanResult!!.userApprovedDecoupledTan == true) {
callback()
}
} }

View File

@ -14,6 +14,7 @@ open class TanMethod(
open val maxTanInputLength: Int? = null, open val maxTanInputLength: Int? = null,
open val allowedTanFormat: AllowedTanFormat? = null, open val allowedTanFormat: AllowedTanFormat? = null,
open val nameOfTanMediumRequired: Boolean = false, open val nameOfTanMediumRequired: Boolean = false,
open val hktanVersion: Int = 6,
open val decoupledParameters: DecoupledTanMethodParameters? = null open val decoupledParameters: DecoupledTanMethodParameters? = null
) { ) {

View File

@ -19,6 +19,10 @@ enum class TanMethodType {
AppTan, AppTan,
DecoupledTan,
DecoupledPushTan,
photoTan, photoTan,
QrCode QrCode

View File

@ -37,8 +37,9 @@ open class ModelMapper(
bank.pinInfo = pinInfo bank.pinInfo = pinInfo
} }
response.getFirstSegmentById<TanInfo>(InstituteSegmentId.TanInfo)?.let { tanInfo -> val tanInfos = response.getSegmentsById<TanInfo>(InstituteSegmentId.TanInfo)
bank.tanMethodsSupportedByBank = mapToTanMethods(tanInfo) if (tanInfos.isNotEmpty()) {
bank.tanMethodsSupportedByBank = tanInfos.flatMap { tanInfo -> mapToTanMethods(tanInfo) }
} }
response.getFirstSegmentById<CommunicationInfo>(InstituteSegmentId.CommunicationInfo)?.let { communicationInfo -> response.getFirstSegmentById<CommunicationInfo>(InstituteSegmentId.CommunicationInfo)?.let { communicationInfo ->
@ -175,11 +176,11 @@ open class ModelMapper(
protected open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> { protected open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> {
return tanInfo.tanProcedureParameters.methodParameters.mapNotNull { return tanInfo.tanProcedureParameters.methodParameters.mapNotNull {
mapToTanMethod(it) mapToTanMethod(it, tanInfo.segmentVersion)
} }
} }
protected open fun mapToTanMethod(parameters: TanMethodParameters): TanMethod? { protected open fun mapToTanMethod(parameters: TanMethodParameters, hktanVersion: Int): TanMethod? {
val methodName = parameters.methodName val methodName = parameters.methodName
// we filter out iTAN and Einschritt-Verfahren as they are not permitted anymore according to PSD2 // we filter out iTAN and Einschritt-Verfahren as they are not permitted anymore according to PSD2
@ -191,7 +192,7 @@ open class ModelMapper(
mapToTanMethodType(parameters) ?: TanMethodType.EnterTan, mapHhdVersion(parameters), mapToTanMethodType(parameters) ?: TanMethodType.EnterTan, mapHhdVersion(parameters),
parameters.maxTanInputLength, parameters.allowedTanFormat, parameters.maxTanInputLength, parameters.allowedTanFormat,
parameters.nameOfTanMediumRequired == BezeichnungDesTanMediumsErforderlich.BezeichnungDesTanMediumsMussAngegebenWerden, parameters.nameOfTanMediumRequired == BezeichnungDesTanMediumsErforderlich.BezeichnungDesTanMediumsMussAngegebenWerden,
mapDecoupledTanMethodParameters(parameters)) hktanVersion, mapDecoupledTanMethodParameters(parameters))
} }
protected open fun mapToTanMethodType(parameters: TanMethodParameters): TanMethodType? { protected open fun mapToTanMethodType(parameters: TanMethodParameters): TanMethodType? {
@ -229,6 +230,10 @@ open class ModelMapper(
tanMethodNameContains(name, "SMS", "mobile", "mTAN") -> TanMethodType.SmsTan tanMethodNameContains(name, "SMS", "mobile", "mTAN") -> TanMethodType.SmsTan
parameters.dkTanMethod == DkTanMethod.Decoupled -> TanMethodType.DecoupledTan
parameters.dkTanMethod == DkTanMethod.DecoupledPush -> TanMethodType.DecoupledPushTan
// 'flateXSecure' identifies itself as 'PPTAN' instead of 'AppTAN' // 'flateXSecure' identifies itself as 'PPTAN' instead of 'AppTAN'
// 'activeTAN-Verfahren' can actually be used either with an app or a reader; it's like chipTAN QR but without a chip card // 'activeTAN-Verfahren' can actually be used either with an app or a reader; it's like chipTAN QR but without a chip card
parameters.dkTanMethod == DkTanMethod.App parameters.dkTanMethod == DkTanMethod.App
@ -275,10 +280,10 @@ open class ModelMapper(
parameters.manualConfirmationAllowedForDecoupled?.let { manualConfirmationAllowed -> parameters.manualConfirmationAllowedForDecoupled?.let { manualConfirmationAllowed ->
return DecoupledTanMethodParameters( return DecoupledTanMethodParameters(
manualConfirmationAllowed, manualConfirmationAllowed,
parameters.periodicStateRequestsAllowedForDecoupled ?: false, // this and the following values are all set when manualConfirmationAllowedForDecoupled is set parameters.periodicDecoupledStateRequestsAllowed ?: false, // this and the following values are all set when manualConfirmationAllowedForDecoupled is set
parameters.maxNumberOfStateRequestsForDecoupled ?: 0, parameters.maxNumberOfStateRequestsForDecoupled ?: 0,
parameters.initialDelayInSecondsForStateRequestsForDecoupled ?: Int.MAX_VALUE, parameters.initialDelayInSecondsForDecoupledStateRequest ?: Int.MAX_VALUE,
parameters.delayInSecondsForNextStateRequestsForDecoupled ?: Int.MAX_VALUE parameters.delayInSecondsForNextDecoupledStateRequests ?: Int.MAX_VALUE
) )
} }

View File

@ -431,7 +431,7 @@ open class ResponseParser(
} }
if (parsedMethodParameters.dkTanMethod == DkTanMethod.Decoupled) { if (parsedMethodParameters.dkTanMethod == DkTanMethod.Decoupled) {
if (parsedMethodParameters.periodicStateRequestsAllowedForDecoupled != null) { if (parsedMethodParameters.periodicDecoupledStateRequestsAllowed != null) {
return 26 return 26
} }
else if (parsedMethodParameters.manualConfirmationAllowedForDecoupled != null) { else if (parsedMethodParameters.manualConfirmationAllowedForDecoupled != null) {

View File

@ -27,10 +27,10 @@ open class TanMethodParameters(
val hhdUcResponseRequired: Boolean, // TODO: wird hierueber gesteuert ob eine TAN eingegeben werden muss (z. B. beim EasyTAN Verfahren muss ja keine eingegeben werden) val hhdUcResponseRequired: Boolean, // TODO: wird hierueber gesteuert ob eine TAN eingegeben werden muss (z. B. beim EasyTAN Verfahren muss ja keine eingegeben werden)
val countSupportedActiveTanMedia: Int?, val countSupportedActiveTanMedia: Int?,
val maxNumberOfStateRequestsForDecoupled: Int? = null, val maxNumberOfStateRequestsForDecoupled: Int? = null,
val initialDelayInSecondsForStateRequestsForDecoupled: Int? = null, val initialDelayInSecondsForDecoupledStateRequest: Int? = null,
val delayInSecondsForNextStateRequestsForDecoupled: Int? = null, val delayInSecondsForNextDecoupledStateRequests: Int? = null,
val manualConfirmationAllowedForDecoupled: Boolean? = null, val manualConfirmationAllowedForDecoupled: Boolean? = null,
val periodicStateRequestsAllowedForDecoupled: Boolean? = null val periodicDecoupledStateRequestsAllowed: Boolean? = null
) { ) {

View File

@ -8,7 +8,7 @@ open class TanMethodSelector {
companion object { companion object {
val NonVisual = listOf(TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan) val NonVisual = listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode) val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode)
@ -16,7 +16,9 @@ open class TanMethodSelector {
open fun getSuggestedTanMethod(tanMethods: List<TanMethod>): TanMethod? { open fun getSuggestedTanMethod(tanMethods: List<TanMethod>): TanMethod? {
return tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan && it.type != TanMethodType.ChipTanManuell } return tanMethods.firstOrNull { it.type == TanMethodType.DecoupledPushTan || it.type == TanMethodType.DecoupledTan } // decoupled TAN method is the most simplistic TAN method, user only has to confirm the action in her TAN app, no manual TAN entering required
?: tanMethods.firstOrNull { it.type == TanMethodType.AppTan } // that's the second most simplistic TAN method: user has to confirm action in her TAN app and then enter the displayed TAN
?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan && it.type != TanMethodType.ChipTanManuell }
?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan } ?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb && it.type != TanMethodType.SmsTan }
?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb } ?: tanMethods.firstOrNull { it.type != TanMethodType.ChipTanUsb }
?: first(tanMethods) ?: first(tanMethods)

View File

@ -1,5 +1,6 @@
package net.dankito.banking.client.model package net.dankito.banking.client.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import net.codinux.banking.fints.model.Currency import net.codinux.banking.fints.model.Currency
@ -35,7 +36,7 @@ open class BankAccount(
open var retrievedTransactionsFrom: LocalDate? = null open var retrievedTransactionsFrom: LocalDate? = null
open var retrievedTransactionsTo: LocalDate? = null open var lastTransactionsRetrievalTime: Instant? = null
open var bookedTransactions: List<AccountTransaction> = listOf() open var bookedTransactions: List<AccountTransaction> = listOf()

View File

@ -799,6 +799,26 @@ class ResponseParserTest : FinTsTestBase() {
?: run { fail("No segment of type TanInfo found in ${result.receivedSegments}") } ?: run { fail("No segment of type TanInfo found in ${result.receivedSegments}") }
} }
@Test
fun parseTanInfo7_DecoupledTanMethod() {
// when
val result = underTest.parse("HITANS:50:7:4+1+1+1+N:N:0:946:2:DECOUPLED:Decoupled::SecureGo plus (Direktfreigabe):::TAN:2048:J:1:N:0:2:N:J:00:0:N:1:150:2:2:J:J'")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.TanInfo, 50, 7, 4)
result.getFirstSegmentById<TanInfo>(InstituteSegmentId.TanInfo)?.let { segment ->
val tanMethodParameters = segment.tanProcedureParameters.methodParameters
assertSize(1, tanMethodParameters)
assertTanMethodParameter(tanMethodParameters, 0, Sicherheitsfunktion.PIN_TAN_946, DkTanMethod.Decoupled, "SecureGo plus (Direktfreigabe)")
assertEquals("DECOUPLED", tanMethodParameters[0].technicalTanMethodIdentification)
}
?: run { fail("No segment of type TanInfo found in ${result.receivedSegments}") }
}
private fun assertTanMethodParameter(parsedTanMethodParameters: List<TanMethodParameters>, index: Int, securityFunction: Sicherheitsfunktion, private fun assertTanMethodParameter(parsedTanMethodParameters: List<TanMethodParameters>, index: Int, securityFunction: Sicherheitsfunktion,
tanMethod: DkTanMethod?, methodName: String) { tanMethod: DkTanMethod?, methodName: String) {
@ -921,10 +941,10 @@ class ResponseParserTest : FinTsTestBase() {
assertEquals(2, decoupledPushTanMethod.countSupportedActiveTanMedia) assertEquals(2, decoupledPushTanMethod.countSupportedActiveTanMedia)
assertEquals(180, decoupledPushTanMethod.maxNumberOfStateRequestsForDecoupled) assertEquals(180, decoupledPushTanMethod.maxNumberOfStateRequestsForDecoupled)
assertEquals(1, decoupledPushTanMethod.initialDelayInSecondsForStateRequestsForDecoupled) assertEquals(1, decoupledPushTanMethod.initialDelayInSecondsForDecoupledStateRequest)
assertEquals(1, decoupledPushTanMethod.delayInSecondsForNextStateRequestsForDecoupled) assertEquals(1, decoupledPushTanMethod.delayInSecondsForNextDecoupledStateRequests)
assertEquals(true, decoupledPushTanMethod.manualConfirmationAllowedForDecoupled) assertEquals(true, decoupledPushTanMethod.manualConfirmationAllowedForDecoupled)
assertEquals(true, decoupledPushTanMethod.periodicStateRequestsAllowedForDecoupled) assertEquals(true, decoupledPushTanMethod.periodicDecoupledStateRequestsAllowed)
} }
?: run { fail("No segment of type TanInfo found in ${result.receivedSegments}") } ?: run { fail("No segment of type TanInfo found in ${result.receivedSegments}") }
} }