Compare commits

...

27 commits

Author SHA1 Message Date
bf17bde9f5 Bumped version to 1.0.0-Alpha-16-SNAPSHOT 2024-10-16 01:36:35 +02:00
9f3e4eff4d Bumped version to 1.0.0-Alpha-15 2024-10-16 01:33:27 +02:00
f89de94aa7 Little refactoring 2024-10-15 21:55:16 +02:00
684b3fb40e Fixed that if lastCreatedMesssage is a DialogInit message, that we don't sent it again as we just initialized a new dialog with initDialogWithStrongCustomerAuthentication() 2024-10-15 21:53:02 +02:00
df692ea222 Renamed messageLogWithoutSensitiveData to messageLog 2024-10-15 13:30:18 +02:00
636963b3d4 Added and finTsModelOrDeserialized to 2024-10-15 13:29:25 +02:00
4802493886 Saving some CPU cycles, only serializing finTsModel if required 2024-10-15 13:28:35 +02:00
ecf930fcad Fixed that quantity is a floating point number 2024-10-15 09:29:52 +02:00
ce3b1d32d7 Renamed messageLogWithoutSensitiveData to messageLog 2024-10-15 02:35:14 +02:00
c39789dfde Fixed only adding -Xdebug if debugger is attached 2024-10-15 02:28:04 +02:00
ab0b676216 Added messageWithoutSensitiveData as extra field so that user can choose between them 2024-10-15 02:05:32 +02:00
20fe60d9f6 Also pretty printing error messages 2024-10-15 02:00:23 +02:00
529caeaa87 Fixed that imageBytes is now nullable 2024-10-14 22:13:32 +02:00
3d6c68e743 Implemented serializer for BankData 2024-10-14 22:12:36 +02:00
7cdb7247c8 Implemented serializing FinTS data 2024-10-14 22:09:14 +02:00
d7d2702869 Retrieving ChangeTanMediaParameters now from supportedJobs instead of storing it a second time 2024-10-14 20:44:31 +02:00
67b58117e1 Mapping PinInfo 2024-10-14 20:20:51 +02:00
66801a1c7a Implemented parsing HICAZS 2024-10-14 20:20:03 +02:00
2410504ede Setting jobsRequiringTan now directly, ignoring PinInfo 2024-10-14 15:45:28 +02:00
2a3b962af5 Made TanMedium serializable 2024-10-10 02:31:00 +02:00
8346fb5077 Using now nullable hashCode() method 2024-10-09 19:12:24 +02:00
8dc2174081 Added mediumName to hashCode() and equals() 2024-10-09 19:08:43 +02:00
05322aface Added toString() implementation 2024-10-04 18:04:36 +02:00
dcbbe043f0 Renamed dialogType to messageType 2024-10-04 18:04:12 +02:00
65d983a5e7 Added a check to determine HHD version 2024-09-26 14:21:10 +02:00
9aad2a5101 Made parsedDataSet, mimeType and imageBytes nullable, as in case of decoding error they are not set 2024-09-26 14:19:27 +02:00
be3a2df6d9 Bumped version to 1.0.0-Alpha-15-SNAPSHOT 2024-09-19 21:15:43 +02:00
59 changed files with 1339 additions and 192 deletions

View file

@ -1,6 +1,6 @@
// TODO: move to versions.gradle
ext {
appVersionName = "1.0.0-Alpha-14"
appVersionName = "1.0.0-Alpha-16-SNAPSHOT"
/* Test */

View file

@ -13,6 +13,10 @@ kotlin {
compilerOptions {
// suppresses compiler warning: [EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING] 'expect'/'actual' classes (including interfaces, objects, annotations, enums, and 'actual' typealiases) are in Beta.
freeCompilerArgs.add("-Xexpect-actual-classes")
if (System.getProperty("idea.debugger.dispatch.addr") != null) {
freeCompilerArgs.add("-Xdebug")
}
}
@ -72,6 +76,7 @@ kotlin {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinxSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
implementation("net.codinux.log:klf:$klfVersion")
}

View file

@ -19,6 +19,8 @@ import net.codinux.banking.fints.response.segments.BankParameters
import net.codinux.banking.fints.util.BicFinder
import net.codinux.log.LogLevel
import net.codinux.log.LoggerFactory
import kotlin.js.JsName
import kotlin.jvm.JvmName
open class FinTsClient(
@ -54,13 +56,13 @@ open class FinTsClient(
open suspend fun getAccountDataAsync(param: GetAccountDataParameter): GetAccountDataResponse {
val basicAccountDataResponse = getRequiredDataToSendUserJobs(param)
val bank = basicAccountDataResponse.finTsModel
if (basicAccountDataResponse.successful == false || param.retrieveOnlyAccountInfo || basicAccountDataResponse.finTsModel == null) {
if (basicAccountDataResponse.successful == false || param.retrieveOnlyAccountInfo || bank == null) {
return GetAccountDataResponse(basicAccountDataResponse.error, basicAccountDataResponse.errorMessage, null,
basicAccountDataResponse.messageLogWithoutSensitiveData, basicAccountDataResponse.finTsModel)
basicAccountDataResponse.messageLog, bank)
} else {
val bank = basicAccountDataResponse.finTsModel!!
return getAccountData(param, bank, bank.accounts, basicAccountDataResponse.messageLogWithoutSensitiveData)
return getAccountData(param, bank, bank.accounts, basicAccountDataResponse.messageLog)
}
}
@ -191,8 +193,8 @@ open class FinTsClient(
* 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)
param.finTsModelOrDeserialized?.let { finTsModel ->
return net.dankito.banking.client.model.response.FinTsClientResponse(null, null, emptyList(), finTsModel)
}
val defaultValues = (param as? GetAccountDataParameter)?.defaultBankValues
@ -211,7 +213,7 @@ open class FinTsClient(
}
protected open suspend fun getAccountInfo(param: FinTsClientParameter, bank: BankData): GetAccountInfoResponse {
param.finTsModel?.let {
param.finTsModelOrDeserialized?.let {
// TODO: implement
// return GetAccountInfoResponse(it)
}

View file

@ -135,7 +135,7 @@ open class FinTsClientDeprecated(
}
open suspend fun changeTanMedium(newActiveTanMedium: TanGeneratorTanMedium, bank: BankData): FinTsClientResponse {
open suspend fun changeTanMedium(newActiveTanMedium: TanMedium, bank: BankData): FinTsClientResponse {
val context = JobContext(JobContextType.ChangeTanMedium, this.callback, config, bank)
val response = config.jobExecutor.changeTanMedium(context, newActiveTanMedium)

View file

@ -341,7 +341,7 @@ open class FinTsJobExecutor(
}
open suspend fun changeTanMedium(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium): BankResponse {
open suspend fun changeTanMedium(context: JobContext, newActiveTanMedium: TanMedium): BankResponse {
val bank = context.bank
if (bank.changeTanMediumParameters?.enteringAtcAndTanRequired == true) {
@ -358,7 +358,7 @@ open class FinTsJobExecutor(
}
}
protected open suspend fun sendChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium, enteredAtc: EnterTanGeneratorAtcResult?): BankResponse {
protected open suspend fun sendChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanMedium, enteredAtc: EnterTanGeneratorAtcResult?): BankResponse {
return sendMessageInNewDialogAndHandleResponse(context, null, true) {
messageBuilder.createChangeTanMediumMessage(context, newActiveTanMedium, enteredAtc?.tan, enteredAtc?.atc)
@ -444,7 +444,7 @@ Log.info { "Terminating waiting for TAN input" } // TODO: remove again
return when (tanMethod.type) {
TanMethodType.ChipTanFlickercode ->
FlickerCodeTanChallenge(
FlickerCodeDecoder().decodeChallenge(challenge, tanMethod.hhdVersion ?: HHDVersion.HHD_1_4), // HHD 1.4 is currently the most used version
FlickerCodeDecoder().decodeChallenge(challenge, tanMethod.hhdVersion ?: getFallbackHhdVersion(challenge)),
forAction, messageToShowToUser, challenge, tanMethod, tanResponse.tanMediaIdentifier, bank, account, tanResponse.tanExpirationTime)
TanMethodType.ChipTanQrCode, TanMethodType.ChipTanPhotoTanMatrixCode,
@ -455,6 +455,14 @@ Log.info { "Terminating waiting for TAN input" } // TODO: remove again
}
}
protected open fun getFallbackHhdVersion(challenge: String): HHDVersion {
if (challenge.length <= 35) { // is this true in all circumstances?
return HHDVersion.HHD_1_3
}
return HHDVersion.HHD_1_4 // HHD 1.4 is currently the most used version
}
protected open suspend fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) {
context.bank.selectedTanMethod.decoupledParameters?.let { decoupledTanMethodParameters ->
if (decoupledTanMethodParameters.periodicStateRequestsAllowed) {
@ -519,7 +527,7 @@ Log.info { "Terminating waiting for TAN input" } // TODO: remove again
if (enteredTanResult.changeTanMethodTo != null) {
return handleUserAsksToChangeTanMethodAndResendLastMessage(context, enteredTanResult.changeTanMethodTo)
} else if (enteredTanResult.changeTanMediumTo is TanGeneratorTanMedium) {
} else if (enteredTanResult.changeTanMediumTo != null) {
return handleUserAsksToChangeTanMediumAndResendLastMessage(context, enteredTanResult.changeTanMediumTo,
enteredTanResult.changeTanMediumResultCallback)
} else if (enteredTanResult.userApprovedDecoupledTan == true && enteredTanResult.responseAfterApprovingDecoupledTan != null) {
@ -554,7 +562,7 @@ Log.info { "Terminating waiting for TAN input" } // TODO: remove again
return resendMessageInNewDialog(context, lastCreatedMessage)
}
protected open suspend fun handleUserAsksToChangeTanMediumAndResendLastMessage(context: JobContext, changeTanMediumTo: TanGeneratorTanMedium,
protected open suspend fun handleUserAsksToChangeTanMediumAndResendLastMessage(context: JobContext, changeTanMediumTo: TanMedium,
changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?): BankResponse {
val lastCreatedMessage = context.dialog.currentMessage
@ -582,7 +590,8 @@ Log.info { "Terminating waiting for TAN input" } // TODO: remove again
val initDialogResponse = initDialogWithStrongCustomerAuthentication(context)
if (initDialogResponse.successful == false) {
// if lastCreatedMessage was a dialog init message, there's no need to send this message again, we just initialized a new dialog in initDialogWithStrongCustomerAuthentication()
if (initDialogResponse.successful == false || lastCreatedMessage.isDialogInitMessage()) {
return initDialogResponse
} else {
val newMessage = messageBuilder.rebuildMessage(context, lastCreatedMessage)

View file

@ -1,6 +1,6 @@
package net.codinux.banking.fints.callback
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.model.*
@ -25,7 +25,7 @@ interface FinTsClientCallback {
*
* If you do not support entering TAN generator ATC, return [EnterTanGeneratorAtcResult.userDidNotEnterAtc]
*/
suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult
suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult
/**
* Gets fired when a FinTS message get sent to bank server, a FinTS message is received from bank server or an error occurred.

View file

@ -1,6 +1,6 @@
package net.codinux.banking.fints.callback
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.model.*
@ -14,7 +14,7 @@ open class NoOpFinTsClientCallback : FinTsClientCallback {
return tanChallenge.userDidNotEnterTan()
}
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult {
return EnterTanGeneratorAtcResult.userDidNotEnterAtc()
}

View file

@ -1,13 +1,13 @@
package net.codinux.banking.fints.callback
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.model.*
open class SimpleFinTsClientCallback(
protected open val askUserForTanMethod: ((supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?) -> TanMethod?)? = null,
protected open val messageLogAdded: ((MessageLogEntry) -> Unit)? = null,
protected open val enterTanGeneratorAtc: ((bank: BankData, tanMedium: TanGeneratorTanMedium) -> EnterTanGeneratorAtcResult)? = null,
protected open val enterTanGeneratorAtc: ((bank: BankData, tanMedium: TanMedium) -> EnterTanGeneratorAtcResult)? = null,
protected open val enterTan: ((tanChallenge: TanChallenge) -> Unit)? = null
) : FinTsClientCallback {
@ -25,7 +25,7 @@ open class SimpleFinTsClientCallback(
enterTan?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
}
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanMedium): EnterTanGeneratorAtcResult {
return enterTanGeneratorAtc?.invoke(bank, tanMedium) ?: EnterTanGeneratorAtcResult.userDidNotEnterAtc()
}

View file

@ -8,10 +8,12 @@ import net.codinux.banking.fints.model.JobContextType
class MessageContext(
val jobType: JobContextType,
val dialogType: MessageType,
val messageType: MessageType,
val jobNumber: Int,
val dialogNumber: Int,
val messageNumber: Int,
val bank: BankData,
val account: AccountData?
)
) {
override fun toString() = "${jobNumber}_${dialogNumber}_$messageNumber ${bank.bankCode} $jobType $messageType"
}

View file

@ -37,32 +37,29 @@ open class MessageLogCollector(
// in either case remove sensitive data after response is parsed as otherwise some information like account holder name and accounts may is not set yet on BankData
open val messageLog: List<MessageLogEntry>
// safe CPU cycles by only formatting and removing sensitive data if messageLog is really requested
get() = _messageLog.map { MessageLogEntry(it.type, it.context, it.messageTrace, createMessageForLog(it), it.error, it.parsedSegments, it.time) }
// safe CPU cycles by only removing sensitive data if messageLog is really requested
get() = _messageLog.map {
val message = createMessageForLog(it)
val messageWithoutSensitiveData = if (options.removeSensitiveDataFromMessageLog) {
safelyRemoveSensitiveDataFromMessage(message, it.context.bank)
} else {
message
}
private fun createMessageForLog(logEntry: MessageLogEntry): String {
val message = if (logEntry.type == MessageLogEntryType.Error) {
MessageLogEntry(it.type, it.context, it.messageTrace, message, messageWithoutSensitiveData, it.error, it.parsedSegments, it.time)
}
private fun createMessageForLog(logEntry: MessageLogEntry): String =
if (logEntry.type == MessageLogEntryType.Error) {
logEntry.message + (if (logEntry.error != null) NewLine + getStackTrace(logEntry.error!!) else "")
} else {
logEntry.message
}
return if (options.removeSensitiveDataFromMessageLog) {
safelyRemoveSensitiveDataFromMessage(message, logEntry.context.bank)
} else {
message
}
}
open fun addMessageLog(type: MessageLogEntryType, message: String, context: MessageContext, parsedSegments: List<ReceivedSegment> = emptyList()) {
val messageTrace = createMessageTraceString(type, context)
val prettyPrintMessage = if (options.collectMessageLog || options.fireCallbackOnMessageLogs || log.isDebugEnabled) { // only use CPU cycles if message will ever be used / displayed
prettyPrintFinTsMessage(message)
} else {
message
}
val prettyPrintMessage = prettyPrintMessageIfRequired(message)
log.debug { "$messageTrace\n$prettyPrintMessage" }
@ -72,15 +69,16 @@ open class MessageLogCollector(
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Throwable? = null) {
val type = MessageLogEntryType.Error
val messageTrace = createMessageTraceString(type, context)
val prettyPrintMessage = prettyPrintFinTsMessage(message) // error messages almost always get logged / displayed -> pretty print
LoggerFactory.getLogger(loggingClass).error(e) { messageTrace + message }
LoggerFactory.getLogger(loggingClass).error(e) { "$messageTrace\n$prettyPrintMessage" }
addMessageLogEntry(type, context, messageTrace, message, e)
addMessageLogEntry(type, context, messageTrace, prettyPrintMessage, e)
}
protected open fun addMessageLogEntry(type: MessageLogEntryType, context: MessageContext, messageTrace: String, message: String, error: Throwable? = null, parsedSegments: List<ReceivedSegment> = emptyList()) {
if (options.collectMessageLog || options.fireCallbackOnMessageLogs) {
val newEntry = MessageLogEntry(type, context, messageTrace, message, error, parsedSegments)
val newEntry = MessageLogEntry(type, context, messageTrace, message, null, error, parsedSegments)
if (options.collectMessageLog) {
_messageLog.add(newEntry)
@ -97,7 +95,7 @@ open class MessageLogCollector(
return "${twoDigits(context.jobNumber)}_${twoDigits(context.dialogNumber)}_${twoDigits(context.messageNumber)}_" +
"${context.bank.bankCode}_${context.bank.customerId}" +
"${ context.account?.let { "_${it.accountIdentifier}" } ?: "" }_" +
"${context.jobType.name}_${context.dialogType.name} " +
"${context.jobType.name}_${context.messageType.name} " +
"${getMessageTypeString(type)}:"
}
@ -113,6 +111,13 @@ open class MessageLogCollector(
}
}
protected open fun prettyPrintMessageIfRequired(message: String): String =
if (options.collectMessageLog || options.fireCallbackOnMessageLogs || log.isDebugEnabled) { // only use CPU cycles if message will ever be used / displayed
prettyPrintFinTsMessage(message)
} else {
message
}
protected open fun prettyPrintFinTsMessage(message: String): String =
finTsUtils.prettyPrintFinTsMessage(message)

View file

@ -1,10 +1,6 @@
package net.codinux.banking.fints.mapper
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.AccountTransaction
import net.dankito.banking.client.model.parameter.FinTsClientParameter

View file

@ -4,10 +4,7 @@ import net.codinux.banking.fints.extensions.randomWithSeed
import net.codinux.banking.fints.messages.datenelemente.implementierte.Aufsetzpunkt
import net.codinux.banking.fints.messages.datenelemente.implementierte.KundensystemID
import net.codinux.banking.fints.messages.datenelemente.implementierte.Synchronisierungsmodus
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedienArtVersion
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumKlasse
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanProcess
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.*
import net.codinux.banking.fints.messages.segmente.Segment
import net.codinux.banking.fints.messages.segmente.Synchronisierung
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
@ -294,7 +291,7 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
}
// TODO: no HKTAN needed?
open fun createChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanGeneratorTanMedium,
open fun createChangeTanMediumMessage(context: JobContext, newActiveTanMedium: TanMedium,
tan: String? = null, atc: Int? = null): MessageBuilderResult {
val result = getSupportedVersionsOfJobForBank(CustomerSegmentId.ChangeTanMedium, context.bank, listOf(1, 2, 3))

View file

@ -1,6 +1,8 @@
package net.codinux.banking.fints.messages
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
import net.codinux.banking.fints.messages.segmente.Segment
import net.codinux.banking.fints.messages.segmente.implementierte.Verarbeitungsvorbereitung
import net.codinux.banking.fints.messages.segmente.implementierte.ZweiSchrittTanEinreichung
@ -32,4 +34,10 @@ open class MessageBuilderResult(
&& messageBodySegments.first() is ZweiSchrittTanEinreichung
}
open fun isDialogInitMessage(): Boolean =
messageBodySegments.any { it is Verarbeitungsvorbereitung }
override fun toString() = "${messageBodySegments.joinToString { (it.dataElementsAndGroups.firstOrNull() as? Segmentkopf)?.let { "${it.identifier}:${it.segmentVersion}" } ?: "<No Segment header>" } }}"
}

View file

@ -1,24 +1,19 @@
package net.codinux.banking.fints.messages.datenelemente.implementierte.tan
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.KontoverbindungInternational
import kotlinx.serialization.Serializable
import net.dankito.banking.client.model.BankAccountIdentifier
@Serializable
open class MobilePhoneTanMedium(
mediumClass: TanMediumKlasse,
status: TanMediumStatus,
override val mediumName: String,
val concealedPhoneNumber: String?,
val phoneNumber: String?,
val smsDebitAccount: KontoverbindungInternational? = null
) : TanMedium(mediumClass, status, mediumName) {
val smsDebitAccount: BankAccountIdentifier? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MobilePhoneTanMedium) return false
if (!super.equals(other)) return false
if (mediumName != other.mediumName) return false
if (concealedPhoneNumber != other.concealedPhoneNumber) return false
if (phoneNumber != other.phoneNumber) return false
if (smsDebitAccount != other.smsDebitAccount) return false
@ -27,17 +22,15 @@ open class MobilePhoneTanMedium(
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + (mediumName.hashCode())
result = 31 * result + (concealedPhoneNumber?.hashCode() ?: 0)
result = 31 * result + (phoneNumber?.hashCode() ?: 0)
result = 31 * result + (smsDebitAccount?.hashCode() ?: 0)
var result = concealedPhoneNumber.hashCode()
result = 31 * result + phoneNumber.hashCode()
result = 31 * result + smsDebitAccount.hashCode()
return result
}
override fun toString(): String {
return super.toString() + " $mediumName ${phoneNumber ?: concealedPhoneNumber ?: ""}"
return phoneNumber ?: concealedPhoneNumber ?: ""
}
}

View file

@ -1,24 +1,21 @@
package net.codinux.banking.fints.messages.datenelemente.implementierte.tan
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
@Serializable
open class TanGeneratorTanMedium(
mediumClass: TanMediumKlasse,
status: TanMediumStatus,
val cardNumber: String,
val cardSequenceNumber: String?,
val cardType: Int?,
val validFrom: LocalDate?,
val validTo: LocalDate?,
mediumName: String?
) : TanMedium(mediumClass, status, mediumName) {
val validTo: LocalDate?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
if (!super.equals(other)) return false
other as TanGeneratorTanMedium
@ -27,25 +24,22 @@ open class TanGeneratorTanMedium(
if (cardType != other.cardType) return false
if (validFrom != other.validFrom) return false
if (validTo != other.validTo) return false
if (mediumName != other.mediumName) return false
return true
}
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + cardNumber.hashCode()
var result = cardNumber.hashCode()
result = 31 * result + cardSequenceNumber.hashCode()
result = 31 * result + (cardType?.hashCode() ?: 0)
result = 31 * result + (validFrom?.hashCode() ?: 0)
result = 31 * result + (validTo?.hashCode() ?: 0)
result = 31 * result + (mediumName?.hashCode() ?: 0)
result = 31 * result + cardType.hashCode()
result = 31 * result + validFrom.hashCode()
result = 31 * result + validTo.hashCode()
return result
}
override fun toString(): String {
return super.toString() + " $mediumName $cardNumber (card sequence number: ${cardSequenceNumber ?: "-"})"
return "$cardNumber (card sequence number: ${cardSequenceNumber ?: "-"})"
}
}

View file

@ -14,13 +14,20 @@ import kotlinx.serialization.Serializable
open class TanMedium(
open val mediumClass: TanMediumKlasse,
open val status: TanMediumStatus,
open val mediumName: String?
open val mediumName: String?,
open val tanGenerator: TanGeneratorTanMedium? = null,
open val mobilePhone: MobilePhoneTanMedium? = null
) {
internal constructor() : this(TanMediumKlasse.AlleMedien, TanMediumStatus.Verfuegbar, null) // for object deserializers
val identifier: String by lazy {
"$mediumClass $mediumName $status ${tanGenerator?.cardNumber} ${mobilePhone?.concealedPhoneNumber ?: mobilePhone?.concealedPhoneNumber}"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
@ -29,6 +36,9 @@ open class TanMedium(
if (mediumClass != other.mediumClass) return false
if (status != other.status) return false
if (mediumName != other.mediumName) return false
if (tanGenerator != other.tanGenerator) return false
if (mobilePhone != other.mobilePhone) return false
return true
}
@ -36,6 +46,9 @@ open class TanMedium(
override fun hashCode(): Int {
var result = mediumClass.hashCode()
result = 31 * result + status.hashCode()
result = 31 * result + mediumName.hashCode()
result = 31 * result + tanGenerator.hashCode()
result = 31 * result + mobilePhone.hashCode()
return result
}

View file

@ -8,7 +8,7 @@ import net.codinux.banking.fints.messages.datenelemente.basisformate.Numerisches
import net.codinux.banking.fints.messages.datenelemente.implementierte.DoNotPrintDatenelement
import net.codinux.banking.fints.messages.datenelemente.implementierte.NotAllowedDatenelement
import net.codinux.banking.fints.messages.datenelemente.implementierte.allCodes
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumKlasse
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
import net.codinux.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
@ -34,13 +34,13 @@ import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
* chipTAN-Verfahren:
* Steht beim chipTAN-Verfahren ein Kartenwechsel an, so kann der Kunde mit diesem Geschäftsvorfall seine Karte bzw.
* Folgekarte aktivieren. Kann der Kunde mehrere Karten verwenden, dann kann mit diesem GV die Ummeldung auf eine
* andere Karte erfolgen. Das Kreditinstitut entscheidet selbst, ob dieser GV TAN-pflichtig istoder nicht.
* andere Karte erfolgen. Das Kreditinstitut entscheidet selbst, ob dieser GV TAN-pflichtig ist oder nicht.
*/
open class TanGeneratorTanMediumAnOderUmmelden(
segmentVersion: Int,
segmentNumber: Int,
bank: BankData,
newActiveTanMedium: TanGeneratorTanMedium,
newActiveTanMedium: TanMedium,
/**
* Has to be set if Eingabe von ATC und TAN erforderlich (BPD)=J
*/
@ -57,17 +57,17 @@ open class TanGeneratorTanMediumAnOderUmmelden(
)
: Segment(listOf(
Segmentkopf(CustomerSegmentId.ChangeTanMedium, segmentVersion, segmentNumber),
Code(TanMediumKlasse.TanGenerator, allCodes<TanMediumKlasse>(), Existenzstatus.Mandatory),
AlphanumerischesDatenelement(newActiveTanMedium.cardNumber, Existenzstatus.Mandatory),
AlphanumerischesDatenelement(newActiveTanMedium.cardSequenceNumber, if (parameters.enteringCardSequenceNumberRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
if (segmentVersion > 1) NumerischesDatenelement(newActiveTanMedium.cardType, 2, if (parameters.enteringCardTypeAllowed) Existenzstatus.Optional else Existenzstatus.NotAllowed) else DoNotPrintDatenelement(),
Code(newActiveTanMedium.mediumClass, allCodes<TanMediumKlasse>(), Existenzstatus.Mandatory),
AlphanumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardNumber, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
AlphanumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardSequenceNumber, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringCardSequenceNumberRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
if (segmentVersion > 1) NumerischesDatenelement(newActiveTanMedium.tanGenerator?.cardType, 2, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringCardTypeAllowed) Existenzstatus.Optional else Existenzstatus.NotAllowed) else DoNotPrintDatenelement(),
if (segmentVersion == 2) Kontoverbindung(bank.accounts.first()) else DoNotPrintDatenelement(),
if (segmentVersion >= 3 && parameters.accountInfoRequired) KontoverbindungInternational(bank.accounts.first(), bank) else DoNotPrintDatenelement(),
if (segmentVersion >= 2) Datum(newActiveTanMedium.validFrom, Existenzstatus.Optional) else DoNotPrintDatenelement(),
if (segmentVersion >= 2) Datum(newActiveTanMedium.validTo, Existenzstatus.Optional) else DoNotPrintDatenelement(),
if (segmentVersion >= 3) AlphanumerischesDatenelement(iccsn, Existenzstatus.Optional, 19) else DoNotPrintDatenelement(),
if (segmentVersion >= 2 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Datum(newActiveTanMedium.tanGenerator?.validFrom, Existenzstatus.Optional) else DoNotPrintDatenelement(),
if (segmentVersion >= 2 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) Datum(newActiveTanMedium.tanGenerator?.validTo, Existenzstatus.Optional) else DoNotPrintDatenelement(),
if (segmentVersion >= 3 && newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator) AlphanumerischesDatenelement(iccsn, Existenzstatus.Optional, 19) else DoNotPrintDatenelement(),
NotAllowedDatenelement(), // TAN-Listennummer not supported anymore
NumerischesDatenelement(atc, 5, if (parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
NumerischesDatenelement(atc, 5, if (newActiveTanMedium.mediumClass == TanMediumKlasse.TanGenerator && parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed),
AlphanumerischesDatenelement(tan, if (parameters.enteringAtcAndTanRequired) Existenzstatus.Mandatory else Existenzstatus.NotAllowed, 99)
)) {

View file

@ -1,12 +1,15 @@
package net.codinux.banking.fints.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import net.codinux.banking.fints.FinTsClient
import net.codinux.banking.fints.messages.datenelemente.abgeleiteteformate.Laenderkennzeichen
import net.codinux.banking.fints.messages.segmente.id.CustomerSegmentId
import net.codinux.banking.fints.response.segments.AccountType
import net.codinux.banking.fints.response.segments.JobParameters
@Serializable
open class AccountData(
open val accountIdentifier: String,
open val subAccountAttribute: String?,
@ -20,6 +23,7 @@ open class AccountData(
open val productName: String?,
open val accountLimit: String?,
open val allowedJobNames: List<String>,
@Transient // can be restored from bank.supportedJobs and this.allowedJobNames
open var allowedJobs: List<JobParameters> = listOf()
) {
@ -40,6 +44,7 @@ open class AccountData(
open var serverTransactionsRetentionDays: Int? = null
@SerialName("supportedFeatures")
protected open val _supportedFeatures = mutableSetOf<AccountFeature>()
open val supportedFeatures: Collection<AccountFeature>

View file

@ -148,10 +148,10 @@ open class AccountTransaction(
result = 31 * result + amount.hashCode()
result = 31 * result + reference.hashCode()
result = 31 * result + bookingDate.hashCode()
result = 31 * result + (otherPartyName?.hashCode() ?: 0)
result = 31 * result + (otherPartyBankId?.hashCode() ?: 0)
result = 31 * result + (otherPartyAccountId?.hashCode() ?: 0)
result = 31 * result + (postingText?.hashCode() ?: 0)
result = 31 * result + otherPartyName.hashCode()
result = 31 * result + otherPartyBankId.hashCode()
result = 31 * result + otherPartyAccountId.hashCode()
result = 31 * result + postingText.hashCode()
result = 31 * result + valueDate.hashCode()
return result
}

View file

@ -1,5 +1,6 @@
package net.codinux.banking.fints.model
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.messages.datenelemente.abgeleiteteformate.Laenderkennzeichen
import net.codinux.banking.fints.messages.datenelemente.implementierte.*
import net.codinux.banking.fints.messages.datenelemente.implementierte.signatur.Sicherheitsfunktion
@ -7,9 +8,9 @@ import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMe
import net.codinux.banking.fints.messages.segmente.id.ISegmentId
import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
import net.codinux.banking.fints.response.segments.JobParameters
import net.codinux.banking.fints.response.segments.PinInfo
import net.codinux.banking.fints.serialization.BankDataSerializer
@Serializable(with = BankDataSerializer::class)
open class BankData(
open var bankCode: String,
open var customerId: String,
@ -30,7 +31,6 @@ open class BankData(
open var selectedTanMethod: TanMethod = TanMethodNotSelected,
open var tanMedia: List<TanMedium> = listOf(),
open var selectedTanMedium: TanMedium? = null,
open var changeTanMediumParameters: ChangeTanMediaParameters? = null,
open var supportedLanguages: List<Dialogsprache> = listOf(),
open var selectedLanguage: Dialogsprache = Dialogsprache.Default,
@ -44,7 +44,8 @@ open class BankData(
open var countMaxJobsPerMessage: Int = 0,
open var supportedHbciVersions: List<HbciVersion> = listOf(),
open var supportedJobs: List<JobParameters> = listOf()
open var supportedJobs: List<JobParameters> = listOf(),
open var jobsRequiringTan: Set<String> = emptySet()
) {
companion object {
@ -62,6 +63,11 @@ open class BankData(
internal constructor() : this("", "", "", "", "") // for object deserializers
open var pinInfo: PinInfo? = null
open val changeTanMediumParameters: ChangeTanMediaParameters?
get() = supportedJobs.filterIsInstance<ChangeTanMediaParameters>().firstOrNull()
protected open val _accounts = mutableListOf<AccountData>()
@ -82,16 +88,6 @@ open class BankData(
}
open var jobsRequiringTan: Set<String> = emptySet()
protected set
open var pinInfo: PinInfo? = null
set(value) {
field = value
// TODO: in case of null: actually in this case it's not allowed to execute job via PIN/TAN at all
jobsRequiringTan = value?.jobTanConfiguration.orEmpty().filter { it.tanRequired }.map { it.segmentId }.toSet()
}
open fun doesJobRequireTan(segmentId: ISegmentId): Boolean = doesJobRequireTan(segmentId.id)
open fun doesJobRequireTan(segmentId: String): Boolean =

View file

@ -11,6 +11,7 @@ open class MessageLogEntry(
open val context: MessageContext,
open val messageTrace: String,
open val message: String,
open val messageWithoutSensitiveData: String? = null,
open val error: Throwable? = null,
/**
* Parsed received segments.
@ -25,7 +26,7 @@ open class MessageLogEntry(
get() = messageTrace + "\n" + message
override fun toString(): String {
return "$type $message"
return "$context $type $message"
}
}

View file

@ -0,0 +1,12 @@
package net.codinux.banking.fints.model
import kotlinx.serialization.Serializable
@Serializable
open class PinInfo(
val minPinLength: Int?,
val maxPinLength: Int?,
val minTanLength: Int?,
val userIdHint: String?,
val customerIdHint: String?
)

View file

@ -11,6 +11,7 @@ import net.codinux.banking.fints.model.*
import net.codinux.banking.fints.response.BankResponse
import net.codinux.banking.fints.response.InstituteSegmentId
import net.codinux.banking.fints.response.segments.*
import net.codinux.banking.fints.response.segments.PinInfo
open class ModelMapper(
@ -34,7 +35,8 @@ open class ModelMapper(
}
response.getFirstSegmentById<PinInfo>(InstituteSegmentId.PinInfo)?.let { pinInfo ->
bank.pinInfo = pinInfo
bank.pinInfo = net.codinux.banking.fints.model.PinInfo(pinInfo.minPinLength, pinInfo.maxPinLength, pinInfo.minTanLength, pinInfo.userIdHint, pinInfo.customerIdHint)
bank.jobsRequiringTan = pinInfo.jobTanConfiguration.filter { it.tanRequired }.map { it.segmentId }.toHashSet()
}
val tanInfos = response.getSegmentsById<TanInfo>(InstituteSegmentId.TanInfo)
@ -54,10 +56,6 @@ open class ModelMapper(
}
}
response.getFirstSegmentById<ChangeTanMediaParameters>(InstituteSegmentId.ChangeTanMediaParameters)?.let { parameters ->
bank.changeTanMediumParameters = parameters
}
if (response.supportedJobs.isNotEmpty()) {
bank.supportedJobs = response.supportedJobs
}
@ -154,7 +152,7 @@ open class ModelMapper(
}
}
protected open fun findTanMethod(securityFunction: Sicherheitsfunktion, bank: BankData): TanMethod? {
open fun findTanMethod(securityFunction: Sicherheitsfunktion, bank: BankData): TanMethod? {
return bank.tanMethodsSupportedByBank.firstOrNull { it.securityFunction == securityFunction }
}
@ -175,7 +173,7 @@ open class ModelMapper(
account.setSupportsFeature(AccountFeature.RealTimeTransfer, messageBuilder.supportsSepaRealTimeTransfer(bank, account))
}
protected open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> {
open fun mapToTanMethods(tanInfo: TanInfo): List<TanMethod> {
return tanInfo.tanProcedureParameters.methodParameters.mapNotNull {
mapToTanMethod(it, tanInfo.segmentVersion)
}

View file

@ -41,6 +41,10 @@ enum class InstituteSegmentId(override val id: String) : ISegmentId {
AccountTransactionsMt940Parameters(AccountTransactionsMt940.id + "S"),
AccountTransactionsCamt("HICAZ"),
AccountTransactionsCamtParameters(AccountTransactionsCamt.id + "S"),
CreditCardTransactions("DIKKU"),
CreditCardTransactionsParameters(CreditCardTransactions.id + "S"),

View file

@ -25,6 +25,7 @@ import net.codinux.banking.fints.response.segments.*
import net.codinux.banking.fints.util.MessageUtils
import net.codinux.banking.fints.extensions.getAllExceptionMessagesJoined
import net.codinux.banking.fints.transactions.swift.Mt535Parser
import net.dankito.banking.client.model.BankAccountIdentifier
open class ResponseParser(
@ -123,6 +124,9 @@ open class ResponseParser(
InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsMt940Parameters.id -> parseMt940AccountTransactionsParameters(segment, segmentId, dataElementGroups)
// InstituteSegmentId.AccountTransactionsCamt.id -> parseCamtAccountTransactions(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsCamtParameters.id -> parseCamtAccountTransactionsParameters(segment, segmentId, dataElementGroups)
InstituteSegmentId.CreditCardTransactions.id -> parseCreditCardTransactions(segment, dataElementGroups)
InstituteSegmentId.CreditCardTransactionsParameters.id -> parseCreditCardTransactionsParameters(segment, segmentId, dataElementGroups)
@ -613,34 +617,32 @@ open class ResponseParser(
val mediumName = if (hitabVersion < 2) null else parseStringToNullIfEmpty(remainingDataElements[10])
return when (mediumClass) {
TanMediumKlasse.TanGenerator -> parseTanGeneratorTanMedium(mediumClass, status, mediumName, hitabVersion, remainingDataElements)
TanMediumKlasse.MobiltelefonMitMobileTan -> parseMobilePhoneTanMedium(mediumClass, status, mediumName, hitabVersion, remainingDataElements)
else -> TanMedium(mediumClass, status, mediumName) // Sparkasse sends for pushTan now class 'AlleMedien' -> set medium name and everything just works fine
}
val tanGenerator = if (mediumClass == TanMediumKlasse.TanGenerator) parseTanGeneratorTanMedium(hitabVersion, remainingDataElements)
else null
val mobilePhone = if (mediumClass == TanMediumKlasse.MobiltelefonMitMobileTan) parseMobilePhoneTanMedium(hitabVersion, remainingDataElements)
else null
return TanMedium(mediumClass, status, mediumName, tanGenerator, mobilePhone) // Sparkasse sends for pushTan now class 'AlleMedien' -> set medium name and everything just works fine
}
protected open fun parseTanGeneratorTanMedium(mediumClass: TanMediumKlasse, status: TanMediumStatus, mediumName: String?,
hitabVersion: Int, dataElements: List<String>): TanGeneratorTanMedium {
protected open fun parseTanGeneratorTanMedium(hitabVersion: Int, dataElements: List<String>): TanGeneratorTanMedium {
val cardType = if (hitabVersion < 2) null else parseNullableInt(dataElements[2])
// TODO: may also parse account info
val validFrom = if (hitabVersion < 2) null else parseNullableDate(dataElements[8])
val validTo = if (hitabVersion < 2) null else parseNullableDate(dataElements[9])
return TanGeneratorTanMedium(mediumClass, status, parseString(dataElements[0]), parseStringToNullIfEmpty(dataElements[1]),
cardType, validFrom, validTo, mediumName)
return TanGeneratorTanMedium(parseString(dataElements[0]), parseStringToNullIfEmpty(dataElements[1]),
cardType, validFrom, validTo)
}
protected open fun parseMobilePhoneTanMedium(mediumClass: TanMediumKlasse, status: TanMediumStatus, mediumName: String?,
hitabVersion: Int, dataElements: List<String>): MobilePhoneTanMedium {
protected open fun parseMobilePhoneTanMedium(hitabVersion: Int, dataElements: List<String>): MobilePhoneTanMedium {
val concealedPhoneNumber = if (hitabVersion < 2) null else parseStringToNullIfEmpty(dataElements[11])
val phoneNumber = if (hitabVersion < 2) null else parseStringToNullIfEmpty(dataElements[12])
val smsDebitAccount: KontoverbindungInternational? = null // TODO: may parse 13th data element to KontoverbindungInternational
val smsDebitAccount: BankAccountIdentifier? = null // TODO: may parse 13th data element to KontoverbindungInternational and map to BankAccountIdentifier
// mediumName should actually never be unset according to spec
return MobilePhoneTanMedium(mediumClass, status, mediumName ?: "", concealedPhoneNumber, phoneNumber, smsDebitAccount)
return MobilePhoneTanMedium(concealedPhoneNumber, phoneNumber, smsDebitAccount)
}
@ -767,6 +769,20 @@ open class ResponseParser(
return RetrieveAccountTransactionsParameters(jobParameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed)
}
protected open fun parseCamtAccountTransactionsParameters(segment: String, segmentId: String, dataElementGroups: List<String>): RetrieveAccountTransactionsParameters {
val jobParameters = parseJobParameters(segment, segmentId, dataElementGroups)
val dataElements = getDataElements(dataElementGroups[4])
val serverTransactionsRetentionDays = parseInt(dataElements[0])
val settingCountEntriesAllowed = parseBoolean(dataElements[1])
val settingAllAccountAllowed = parseBoolean(dataElements[2])
val supportedCamtDataFormats = dataElements.subList(3, dataElements.size)
return RetrieveAccountTransactionsParameters(jobParameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed, supportedCamtDataFormats)
}
protected open fun parseCreditCardTransactions(segment: String, dataElementGroups: List<String>): ReceivedCreditCardTransactionsAndBalance {
val balance = parseBalance(dataElementGroups[3])

View file

@ -1,17 +1,13 @@
package net.codinux.banking.fints.response.segments
import net.codinux.banking.fints.messages.Separators
import kotlin.jvm.Transient
open class ReceivedSegment(
open val segmentId: String,
@Transient
open val segmentNumber: Int,
open val segmentVersion: Int,
@Transient
open val referenceSegmentNumber: Int? = null,
@Transient
open val segmentString: String
) {

View file

@ -5,9 +5,14 @@ open class RetrieveAccountTransactionsParameters(
parameters: JobParameters,
open val serverTransactionsRetentionDays: Int,
open val settingCountEntriesAllowed: Boolean,
open val settingAllAccountAllowed: Boolean
open val settingAllAccountAllowed: Boolean,
open val supportedCamtDataFormats: List<String> = emptyList()
) : JobParameters(parameters) {
internal constructor() : this(JobParameters(), -1, false, false) // for object deserializers
// for languages not supporting default parameters
constructor(parameters: JobParameters, serverTransactionsRetentionDays: Int, settingCountEntriesAllowed: Boolean, settingAllAccountAllowed: Boolean) :
this(parameters, serverTransactionsRetentionDays, settingCountEntriesAllowed, settingAllAccountAllowed, emptyList())
}

View file

@ -0,0 +1,30 @@
package net.codinux.banking.fints.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import net.codinux.banking.fints.model.BankData
object BankDataSerializer : KSerializer<BankData> {
private val serializer = SerializedFinTsData.serializer()
private val mapper = SerializedFinTsDataMapper()
override val descriptor: SerialDescriptor = serializer.descriptor
override fun serialize(encoder: Encoder, value: BankData) {
val surrogate = mapper.map(value)
encoder.encodeSerializableValue(serializer, surrogate)
}
override fun deserialize(decoder: Decoder): BankData {
val surrogate = decoder.decodeSerializableValue(serializer)
return mapper.map(surrogate)
}
}

View file

@ -0,0 +1,48 @@
package net.codinux.banking.fints.serialization
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.codinux.banking.fints.model.BankData
import net.codinux.log.logger
object FinTsModelSerializer {
private val json: Json by lazy {
Json { this.ignoreUnknownKeys = true }
}
private val prettyPrintJson by lazy {
Json {
this.ignoreUnknownKeys = true
this.prettyPrint = true
}
}
private val mapper = SerializedFinTsDataMapper()
private val log by logger()
fun serializeToJson(bank: BankData, prettyPrint: Boolean = false): String? {
return try {
val serializableData = mapper.map(bank)
val json = if (prettyPrint) prettyPrintJson else json
json.encodeToString(serializableData)
} catch (e: Throwable) {
log.error(e) { "Could not map fints4k model to JSON" }
null
}
}
fun deserializeFromJson(serializedFinTsData: String): BankData? = try {
val serializedData = json.decodeFromString<SerializedFinTsData>(serializedFinTsData)
mapper.map(serializedData)
} catch (e: Throwable) {
log.error(e) { "Could not deserialize BankData from JSON:\n$serializedFinTsData"}
null
}
}

View file

@ -0,0 +1,57 @@
package net.codinux.banking.fints.serialization
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import net.codinux.banking.fints.messages.datenelemente.implementierte.*
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.model.AccountData
import net.codinux.banking.fints.model.PinInfo
import net.codinux.banking.fints.model.TanMethod
import net.codinux.banking.fints.serialization.jobparameter.DetailedSerializableJobParameters
import net.codinux.banking.fints.serialization.jobparameter.SerializableJobParameters
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class SerializedFinTsData(
val bankCode: String,
val customerId: String,
val pin: String,
val finTs3ServerAddress: String,
val bic: String,
val bankName: String,
val countryCode: Int,
val bpdVersion: Int,
val userId: String,
val customerName: String,
val updVersion: Int,
val tanMethodsSupportedByBank: List<TanMethod>,
val identifierOfTanMethodsAvailableForUser: List<String> = listOf(),
val selectedTanMethodIdentifier: String,
val tanMedia: List<TanMedium> = listOf(),
val selectedTanMediumIdentifier: String? = null,
val supportedLanguages: List<Dialogsprache> = listOf(),
val selectedLanguage: Dialogsprache = Dialogsprache.Default,
val customerSystemId: String = KundensystemID.Anonymous,
val customerSystemStatus: KundensystemStatusWerte = KundensystemStatus.SynchronizingCustomerSystemId,
val countMaxJobsPerMessage: Int = 0,
val supportedHbciVersions: List<HbciVersion> = listOf(),
val supportedJobs: List<SerializableJobParameters> = listOf(),
val supportedDetailedJobs: List<DetailedSerializableJobParameters> = listOf(),
val jobsRequiringTan: Set<String> = emptySet(),
val pinInfo: PinInfo? = null,
val accounts: List<AccountData>
) {
@EncodeDefault
private val modelVersion: String = "0.6.0"
}

View file

@ -0,0 +1,138 @@
package net.codinux.banking.fints.serialization
import net.codinux.banking.fints.model.BankData
import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
import net.codinux.banking.fints.response.segments.JobParameters
import net.codinux.banking.fints.response.segments.RetrieveAccountTransactionsParameters
import net.codinux.banking.fints.response.segments.SepaAccountInfoParameters
import net.codinux.banking.fints.serialization.jobparameter.*
import net.codinux.log.logger
class SerializedFinTsDataMapper {
private val log by logger()
fun map(bank: BankData) = SerializedFinTsData(
bank.bankCode,
bank.customerId,
bank.pin,
bank.finTs3ServerAddress,
bank.bic,
bank.bankName,
bank.countryCode,
bank.bpdVersion,
bank.userId,
bank.customerName,
bank.updVersion,
bank.tanMethodsSupportedByBank,
bank.tanMethodsAvailableForUser.map { it.securityFunction.code },
bank.selectedTanMethod.securityFunction.code,
bank.tanMedia,
bank.selectedTanMedium?.identifier,
bank.supportedLanguages,
bank.selectedLanguage,
bank.customerSystemId,
bank.customerSystemStatus,
bank.countMaxJobsPerMessage,
bank.supportedHbciVersions,
bank.supportedJobs.filterNot { isDetailedJobParameters(it) }.map { mapJobParameters(it) },
bank.supportedJobs.filter { isDetailedJobParameters(it) }.mapNotNull { mapDetailedJobParameters(it) },
bank.jobsRequiringTan,
bank.pinInfo,
bank.accounts
)
private fun isDetailedJobParameters(parameters: JobParameters): Boolean =
parameters is RetrieveAccountTransactionsParameters
|| parameters is SepaAccountInfoParameters
|| parameters is ChangeTanMediaParameters
private fun mapJobParameters(parameters: JobParameters) = SerializableJobParameters(
parameters.jobName,
parameters.maxCountJobs,
parameters.minimumCountSignatures,
parameters.securityClass,
parameters.segmentId,
parameters.segmentNumber,
parameters.segmentVersion,
parameters.segmentString
)
private fun mapDetailedJobParameters(parameters: JobParameters): DetailedSerializableJobParameters? = when (parameters) {
is RetrieveAccountTransactionsParameters -> SerializableRetrieveAccountTransactionsParameters(mapJobParameters(parameters), parameters.serverTransactionsRetentionDays, parameters.settingCountEntriesAllowed, parameters.settingAllAccountAllowed, parameters.supportedCamtDataFormats)
is SepaAccountInfoParameters -> SerializableSepaAccountInfoParameters(mapJobParameters(parameters), parameters.retrieveSingleAccountAllowed, parameters.nationalAccountRelationshipAllowed, parameters.structuredReferenceAllowed, parameters.settingMaxAllowedEntriesAllowed, parameters.countReservedReferenceLength, parameters.supportedSepaFormats)
is ChangeTanMediaParameters -> SerializableChangeTanMediaParameters(mapJobParameters(parameters), parameters.enteringTanListNumberRequired, parameters.enteringCardSequenceNumberRequired, parameters.enteringAtcAndTanRequired, parameters.enteringCardTypeAllowed, parameters.accountInfoRequired, parameters.allowedCardTypes)
else -> {
log.warn { "${parameters::class} is said to be a DetailedJobParameters class, but found no mapping code for it" }
null
}
}
fun map(bank: SerializedFinTsData) = BankData(
bank.bankCode,
bank.customerId,
bank.pin,
bank.finTs3ServerAddress,
bank.bic,
bank.bankName,
bank.countryCode,
bank.bpdVersion,
bank.userId,
bank.customerName,
bank.updVersion,
bank.tanMethodsSupportedByBank,
bank.tanMethodsSupportedByBank.filter { it.securityFunction.code in bank.identifierOfTanMethodsAvailableForUser },
bank.tanMethodsSupportedByBank.first { it.securityFunction.code == bank.selectedTanMethodIdentifier },
bank.tanMedia,
bank.selectedTanMediumIdentifier?.let { id -> bank.tanMedia.firstOrNull { it.identifier == id } },
bank.supportedLanguages,
bank.selectedLanguage,
bank.customerSystemId,
bank.customerSystemStatus,
bank.countMaxJobsPerMessage,
bank.supportedHbciVersions,
bank.supportedJobs.map { mapJobParameters(it) } + bank.supportedDetailedJobs.map { mapDetailedJobParameters(it) },
bank.jobsRequiringTan
).apply {
pinInfo = bank.pinInfo
bank.accounts.forEach { account ->
account.allowedJobs = this.supportedJobs.filter { it.jobName in account.allowedJobNames }
this.addAccount(account)
}
}
private fun mapJobParameters(parameters: SerializableJobParameters) = JobParameters(
parameters.jobName,
parameters.maxCountJobs,
parameters.minimumCountSignatures,
parameters.securityClass,
parameters.segmentString
)
private fun mapDetailedJobParameters(parameters: DetailedSerializableJobParameters): JobParameters = when (parameters) {
is SerializableRetrieveAccountTransactionsParameters -> RetrieveAccountTransactionsParameters(mapJobParameters(parameters.jobParameters), parameters.serverTransactionsRetentionDays, parameters.settingCountEntriesAllowed, parameters.settingAllAccountAllowed, parameters.supportedCamtDataFormats)
is SerializableSepaAccountInfoParameters -> SepaAccountInfoParameters(mapJobParameters(parameters.jobParameters), parameters.retrieveSingleAccountAllowed, parameters.nationalAccountRelationshipAllowed, parameters.structuredReferenceAllowed, parameters.settingMaxAllowedEntriesAllowed, parameters.countReservedReferenceLength, parameters.supportedSepaFormats)
is SerializableChangeTanMediaParameters -> ChangeTanMediaParameters(mapJobParameters(parameters.jobParameters), parameters.enteringTanListNumberRequired, parameters.enteringCardSequenceNumberRequired, parameters.enteringAtcAndTanRequired, parameters.enteringCardTypeAllowed, parameters.accountInfoRequired, parameters.allowedCardTypes)
}
}

View file

@ -0,0 +1,13 @@
package net.codinux.banking.fints.serialization.jobparameter
import kotlinx.serialization.Serializable
@Serializable
sealed class DetailedSerializableJobParameters {
abstract val jobParameters: SerializableJobParameters
override fun toString() = jobParameters.toString()
}

View file

@ -0,0 +1,17 @@
package net.codinux.banking.fints.serialization.jobparameter
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("ChangeTanMediaParameters")
class SerializableChangeTanMediaParameters(
override val jobParameters: SerializableJobParameters,
val enteringTanListNumberRequired: Boolean,
val enteringCardSequenceNumberRequired: Boolean,
val enteringAtcAndTanRequired: Boolean,
val enteringCardTypeAllowed: Boolean,
val accountInfoRequired: Boolean,
val allowedCardTypes: List<Int>
) : DetailedSerializableJobParameters()

View file

@ -0,0 +1,19 @@
package net.codinux.banking.fints.serialization.jobparameter
import kotlinx.serialization.Serializable
@Serializable
class SerializableJobParameters(
val jobName: String,
val maxCountJobs: Int,
val minimumCountSignatures: Int,
val securityClass: Int?,
val segmentId: String,
val segmentNumber: Int,
val segmentVersion: Int,
val segmentString: String
) {
override fun toString() = "$jobName $segmentVersion"
}

View file

@ -0,0 +1,17 @@
package net.codinux.banking.fints.serialization.jobparameter
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("RetrieveAccountTransactionsParameters")
class SerializableRetrieveAccountTransactionsParameters(
override val jobParameters: SerializableJobParameters,
val serverTransactionsRetentionDays: Int,
val settingCountEntriesAllowed: Boolean,
val settingAllAccountAllowed: Boolean,
val supportedCamtDataFormats: List<String> = emptyList()
) : DetailedSerializableJobParameters() {
override fun toString() = "${super.toString()}, serverTransactionsRetentionDays = $serverTransactionsRetentionDays, supportedCamtDataFormats = $supportedCamtDataFormats"
}

View file

@ -0,0 +1,19 @@
package net.codinux.banking.fints.serialization.jobparameter
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("SepaAccountInfoParameters")
class SerializableSepaAccountInfoParameters(
override val jobParameters: SerializableJobParameters,
val retrieveSingleAccountAllowed: Boolean,
val nationalAccountRelationshipAllowed: Boolean,
val structuredReferenceAllowed: Boolean,
val settingMaxAllowedEntriesAllowed: Boolean,
val countReservedReferenceLength: Int,
val supportedSepaFormats: List<String>
) : DetailedSerializableJobParameters() {
override fun toString() = "${super.toString()}, supportedSepaFormats = $supportedSepaFormats"
}

View file

@ -3,12 +3,12 @@ package net.codinux.banking.fints.tan
open class FlickerCode(
val challengeHHD_UC: String,
val parsedDataSet: String,
val parsedDataSet: String? = null,
val decodingError: Exception? = null
) {
val decodingSuccessful: Boolean
get() = decodingError == null
get() = parsedDataSet != null
override fun toString(): String {

View file

@ -45,7 +45,7 @@ open class FlickerCodeDecoder {
} catch (e: Exception) {
log.error(e) { "Could not decode challenge $challengeHHD_UC" }
return FlickerCode(challengeHHD_UC, "", e)
return FlickerCode(challengeHHD_UC, null, e)
}
}

View file

@ -2,13 +2,13 @@ package net.codinux.banking.fints.tan
open class TanImage(
val mimeType: String,
val imageBytes: ByteArray,
val mimeType: String? = null,
val imageBytes: ByteArray? = null,
val decodingError: Exception? = null
) {
val decodingSuccessful: Boolean
get() = decodingError == null
get() = mimeType != null && imageBytes != null
override fun toString(): String {
@ -16,7 +16,7 @@ open class TanImage(
return "Decoding error: $decodingError"
}
return "$mimeType ${imageBytes.size} bytes"
return "$mimeType ${imageBytes?.size} bytes"
}
}

View file

@ -29,7 +29,7 @@ open class TanImageDecoder {
} catch (e: Exception) {
log.error(e) { "Could not decode challenge HHD_UC to TanImage: $challengeHHD_UC" }
return TanImage("", ByteArray(0), e)
return TanImage(null, null, e)
}
}

View file

@ -145,7 +145,7 @@ open class Mt535Parser(
val (marketValue, pricingTime, totalCostPrice) = parseMarketValue(holdingBlock)
val balance = portfolioValue?.first ?: (if (balanceIsQuantity == false) Amount(totalBalance) else null)
val quantity = if (balanceIsQuantity) totalBalance.replace(",", "").toIntOrNull() else null
val quantity = if (balanceIsQuantity) totalBalance.replace(",", ".").toDoubleOrNull() else null
Holding(name, isin, wkn, buyingDate, quantity, averageCostPrice, balance, portfolioValue?.second ?: averageCostPriceCurrency, marketValue, pricingTime, totalCostPrice)
} catch (e: Throwable) {

View file

@ -11,7 +11,7 @@ data class Holding(
val isin: String?,
val wkn: String?,
val buyingDate: LocalDate?,
val quantity: Int?,
val quantity: Double?,
/**
* (Durchschnittlicher) Einstandspreis/-kurs einer Einheit des Wertpapiers
*/

View file

@ -85,10 +85,10 @@ open class AccountTransaction(
var result = amount.hashCode()
result = 31 * result + reference.hashCode()
result = 31 * result + bookingDate.hashCode()
result = 31 * result + (otherPartyName?.hashCode() ?: 0)
result = 31 * result + (otherPartyBankId?.hashCode() ?: 0)
result = 31 * result + (otherPartyAccountId?.hashCode() ?: 0)
result = 31 * result + (postingText?.hashCode() ?: 0)
result = 31 * result + otherPartyName.hashCode()
result = 31 * result + otherPartyBankId.hashCode()
result = 31 * result + otherPartyAccountId.hashCode()
result = 31 * result + postingText.hashCode()
result = 31 * result + valueDate.hashCode()
return result
}

View file

@ -2,6 +2,7 @@ package net.dankito.banking.client.model.parameter
import net.codinux.banking.fints.model.BankData
import net.codinux.banking.fints.model.TanMethodType
import net.codinux.banking.fints.serialization.FinTsModelSerializer
import net.dankito.banking.client.model.CustomerCredentials
@ -15,5 +16,12 @@ open class FinTsClientParameter(
open val tanMethodsNotSupportedByApplication: List<TanMethodType>? = null,
open val preferredTanMedium: String? = null, // the ID of the medium
open val abortIfTanIsRequired: Boolean = false,
open val finTsModel: BankData? = null
) : CustomerCredentials(bankCode, loginName, password)
open val finTsModel: BankData? = null,
open val serializedFinTsModel: String? = null
) : CustomerCredentials(bankCode, loginName, password) {
open val finTsModelOrDeserialized: BankData? by lazy {
finTsModel ?: serializedFinTsModel?.let { FinTsModelSerializer.deserializeFromJson(it) }
}
}

View file

@ -25,8 +25,9 @@ open class GetAccountDataParameter(
preferredTanMedium: String? = null,
abortIfTanIsRequired: Boolean = false,
finTsModel: BankData? = null,
serializedFinTsModel: String? = null,
open val defaultBankValues: BankData? = null
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel) {
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel, serializedFinTsModel) {
open val retrieveOnlyAccountInfo: Boolean
get() = retrieveBalance == false && retrieveTransactions == RetrieveTransactions.No

View file

@ -38,7 +38,8 @@ open class TransferMoneyParameter(
preferredTanMedium: String? = null,
abortIfTanIsRequired: Boolean = false,
finTsModel: BankData? = null,
serializedFinTsModel: String? = null,
open val selectAccountToUseForTransfer: ((List<AccountData>) -> AccountData?)? = null // TODO: use BankAccount instead of AccountData
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel)
) : FinTsClientParameter(bankCode, loginName, password, preferredTanMethods, tanMethodsNotSupportedByApplication, preferredTanMedium, abortIfTanIsRequired, finTsModel, serializedFinTsModel)

View file

@ -2,13 +2,14 @@ package net.dankito.banking.client.model.response
import net.codinux.banking.fints.model.BankData
import net.codinux.banking.fints.model.MessageLogEntry
import net.codinux.banking.fints.serialization.FinTsModelSerializer
// TODO: rename to BankingClientResponse?
open class FinTsClientResponse(
open val error: ErrorCode?,
open val errorMessage: String?,
open val messageLogWithoutSensitiveData: List<MessageLogEntry>,
open val messageLog: List<MessageLogEntry>,
open val finTsModel: BankData? = null
) {
@ -21,4 +22,7 @@ open class FinTsClientResponse(
open val errorCodeAndMessage: String
get() = "$error${errorMessage?.let { " $it" }}"
// save some CPU cycles, only serialize finTsModel if required
open val serializedFinTsModel: String? by lazy { finTsModel?.let { FinTsModelSerializer.serializeToJson(it) } }
}

View file

@ -9,9 +9,9 @@ open class GetAccountDataResponse(
error: ErrorCode?,
errorMessage: String?,
open val customerAccount: CustomerAccount?,
messageLogWithoutSensitiveData: List<MessageLogEntry>,
messageLog: List<MessageLogEntry>,
finTsModel: BankData? = null
) : FinTsClientResponse(error, errorMessage, messageLogWithoutSensitiveData, finTsModel) {
) : FinTsClientResponse(error, errorMessage, messageLog, finTsModel) {
internal constructor() : this(null, null, null, listOf()) // for object deserializers

View file

@ -7,6 +7,6 @@ import net.codinux.banking.fints.model.MessageLogEntry
open class TransferMoneyResponse(
error: ErrorCode?,
errorMessage: String?,
messageLogWithoutSensitiveData: List<MessageLogEntry>,
messageLog: List<MessageLogEntry>,
finTsModel: BankData? = null
) : FinTsClientResponse(error, errorMessage, messageLogWithoutSensitiveData, finTsModel)
) : FinTsClientResponse(error, errorMessage, messageLog, finTsModel)

View file

@ -62,13 +62,10 @@ abstract class FinTsTestBase {
val ClientConfig = FinTsClientConfiguration(FinTsClientOptions(version = ProductVersion, productName = ProductName))
init {
Bank.changeTanMediumParameters = ChangeTanMediaParameters(JobParameters("", 1, 1, 1, ":0:0"), false, false, false, false, false, listOf())
}
fun createTestBank(): BankData {
return BankData(BankCode, CustomerId, Pin, BankFinTsServerAddress, Bic, "", BankCountryCode, selectedTanMethod = TanMethod("chipTAN-optisch", SecurityFunction, TanMethodType.ChipTanFlickercode), selectedLanguage = Language)
return BankData(BankCode, CustomerId, Pin, BankFinTsServerAddress, Bic, "", BankCountryCode, selectedTanMethod = TanMethod("chipTAN-optisch", SecurityFunction, TanMethodType.ChipTanFlickercode), selectedLanguage = Language, supportedJobs = listOf(
ChangeTanMediaParameters(JobParameters("", 1, 1, 1, ":0:0"), false, false, false, false, false, listOf())
))
}
fun createTestAccount(): AccountData {
@ -122,15 +119,15 @@ abstract class FinTsTestBase {
createAllowedJob(CustomerSegmentId.CreditCardTransactions, 2),
SepaAccountInfoParameters(createAllowedJob(CustomerSegmentId.SepaBankTransfer, 1), true, true, true, true, 35, listOf("pain.001.001.03")),
SepaAccountInfoParameters(createAllowedJob(CustomerSegmentId.SepaRealTimeTransfer, 1), true, true, true, true, 35, listOf("pain.001.001.03")),
ChangeTanMediaParameters(changeTanMediumJob, false, false, false, false, false, listOf())
)
bank.jobsRequiringTan = setOf(
CustomerSegmentId.Balance.id,
CustomerSegmentId.AccountTransactionsMt940.id,
CustomerSegmentId.CreditCardTransactions.id,
CustomerSegmentId.SepaBankTransfer.id,
CustomerSegmentId.SepaRealTimeTransfer.id
)
bank.pinInfo = PinInfo(getTransactionsJob, null, null, null, null, null, listOf(
JobTanConfiguration(CustomerSegmentId.Balance.id, true),
JobTanConfiguration(CustomerSegmentId.AccountTransactionsMt940.id, true),
JobTanConfiguration(CustomerSegmentId.CreditCardTransactions.id, true),
JobTanConfiguration(CustomerSegmentId.SepaBankTransfer.id, true),
JobTanConfiguration(CustomerSegmentId.SepaRealTimeTransfer.id, true)
))
bank.changeTanMediumParameters = ChangeTanMediaParameters(changeTanMediumJob, false, false, false, false, false, listOf())
val checkingAccount = AccountData(CustomerId, null, BankCountryCode, BankCode, "ABCDDEBBXXX", CustomerId, AccountType.Girokonto, "EUR", "", null, null, bank.supportedJobs.map { it.jobName }, bank.supportedJobs)
bank.addAccount(checkingAccount)
@ -149,7 +146,7 @@ abstract class FinTsTestBase {
3 -> messageBuilder.createInitDialogMessageWithoutStrongCustomerAuthentication(context, null)
4 -> messageBuilder.createSynchronizeCustomerSystemIdMessage(context)
5 -> messageBuilder.createGetTanMediaListMessage(context)
6 -> messageBuilder.createChangeTanMediumMessage(context, TanGeneratorTanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Aktiv, "", null, null, null, null, null), null, null)
6 -> messageBuilder.createChangeTanMediumMessage(context, TanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Aktiv, null, TanGeneratorTanMedium("", null, null, null, null)), null, null)
7 -> messageBuilder.createGetBalanceMessage(context, account)
8 -> messageBuilder.createGetTransactionsMessage(context, GetAccountTransactionsParameter(bank, account, true))
9 -> messageBuilder.createGetTransactionsMessage(context, GetAccountTransactionsParameter(bank, bank.accounts[1], true))

View file

@ -164,7 +164,7 @@ class MessageBuilderTest : FinTsTestBase() {
// given
val getTransactionsJob = RetrieveAccountTransactionsParameters(JobParameters(CustomerSegmentId.AccountTransactionsMt940.id, 1, 1, null, "HIKAZS:73:5"), 180, true, false)
bank.supportedJobs = listOf(getTransactionsJob)
bank.pinInfo = PinInfo(getTransactionsJob, null, null, null, null, null, listOf(JobTanConfiguration(CustomerSegmentId.AccountTransactionsMt940.id, true)))
bank.jobsRequiringTan = setOf(CustomerSegmentId.AccountTransactionsMt940.id)
val account = AccountData(CustomerId, null, BankCountryCode, BankCode, null, CustomerId, AccountType.Girokonto, "EUR", "", null, null, listOf(getTransactionsJob.jobName), listOf(getTransactionsJob))
bank.addAccount(account)
@ -198,7 +198,7 @@ class MessageBuilderTest : FinTsTestBase() {
// given
val getTransactionsJob = RetrieveAccountTransactionsParameters(JobParameters(CustomerSegmentId.AccountTransactionsMt940.id, 1, 1, null, "HIKAZS:73:5"), 180, true, false)
bank.supportedJobs = listOf(getTransactionsJob)
bank.pinInfo = PinInfo(getTransactionsJob, null, null, null, null, null, listOf(JobTanConfiguration(CustomerSegmentId.AccountTransactionsMt940.id, true)))
bank.jobsRequiringTan = setOf(CustomerSegmentId.AccountTransactionsMt940.id)
val account = AccountData(CustomerId, null, BankCountryCode, BankCode, null, CustomerId, AccountType.Girokonto, "EUR", "", null, null, listOf(getTransactionsJob.jobName), listOf(getTransactionsJob))
bank.addAccount(account)

View file

@ -2,6 +2,7 @@ package net.codinux.banking.fints.messages.segmente.implementierte.tan
import net.codinux.banking.fints.FinTsTestBase
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumKlasse
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumStatus
import net.codinux.banking.fints.response.segments.ChangeTanMediaParameters
@ -25,7 +26,7 @@ class TanGeneratorTanMediumAnOderUmmeldenTest: FinTsTestBase() {
private const val SegmentNumber = 3
private val NewActiveTanMedium = TanGeneratorTanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Verfuegbar, CardNumber, CardSequenceNumber, CardType, null, null, "EC-Card")
private val NewActiveTanMedium = TanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Verfuegbar, "EC-Card", TanGeneratorTanMedium(CardNumber, CardSequenceNumber, CardType, null, null))
}

View file

@ -1021,8 +1021,8 @@ class ResponseParserTest : FinTsTestBase() {
result.getFirstSegmentById<TanMediaList>(InstituteSegmentId.TanMediaList)?.let { segment ->
assertEquals(TanEinsatzOption.KundeKannGenauEinMediumZuEinerZeitNutzen, segment.usageOption)
assertContainsExactly(segment.tanMedia,
TanGeneratorTanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.AktivFolgekarte, oldCardNumber, cardSequenceNumber, null, null, null, mediaName),
TanGeneratorTanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Verfuegbar, cardSequenceNumber, null, null, null, null, mediaName)
TanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.AktivFolgekarte, mediaName, TanGeneratorTanMedium(oldCardNumber, cardSequenceNumber, null, null, null)),
TanMedium(TanMediumKlasse.TanGenerator, TanMediumStatus.Verfuegbar, mediaName, TanGeneratorTanMedium(cardSequenceNumber, null, null, null, null))
)
}
?: run { fail("No segment of type TanMediaList found in ${result.receivedSegments}") }
@ -1226,6 +1226,24 @@ class ResponseParserTest : FinTsTestBase() {
}
@Test
fun parseAccountTransactionsCamtParameters() {
val result = underTest.parse("HICAZS:56:1:3+1+1+1+740:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08'")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.AccountTransactionsCamtParameters, 56, 1, 3)
result.getFirstSegmentById<RetrieveAccountTransactionsParameters>(InstituteSegmentId.AccountTransactionsCamtParameters)?.let { segment ->
assertEquals(740, segment.serverTransactionsRetentionDays)
assertFalse(segment.settingCountEntriesAllowed)
assertFalse(segment.settingAllAccountAllowed)
assertContainsExactly(segment.supportedCamtDataFormats, "urn:iso:std:iso:20022:tech:xsd:camt.052.001.02", "urn:iso:std:iso:20022:tech:xsd:camt.052.001.08")
}
?: run { fail("No segment of type AccountTransactionsCamtParameters found in ${result.receivedSegments}") }
}
@Test
fun parseCreditCardAccountTransactions() {

View file

@ -0,0 +1,59 @@
package net.codinux.banking.fints.serialization
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.codinux.banking.fints.model.BankData
import net.codinux.banking.fints.test.TestDataGenerator
import net.codinux.banking.fints.test.assertContains
import net.codinux.banking.fints.test.assertSize
import net.codinux.banking.fints.test.assertTrue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class BankDataSerializerTest {
private val serializedBankData = TestDataGenerator.serializedBankData
private val json = Json {
prettyPrint = true
}
@Test
fun serializeToJson() {
val bank = TestDataGenerator.generateBankDataForSerialization()
val result = json.encodeToString(bank)
assertEquals(serializedBankData, result)
}
@Test
fun deserializeFromJson() {
val result = json.decodeFromString<BankData>(serializedBankData)
assertNotNull(result)
assertSize(8, result.tanMethodsSupportedByBank)
assertSize(4, result.tanMethodsAvailableForUser)
assertContains(result.tanMethodsSupportedByBank, result.tanMethodsAvailableForUser) // check that it contains exactly the same object instances
assertNotNull(result.selectedTanMethod)
assertContains(result.tanMethodsSupportedByBank, result.selectedTanMethod) // check that it contains exactly the same object instance
assertSize(3, result.tanMedia)
assertNotNull(result.selectedTanMedium)
assertContains(result.tanMedia, result.selectedTanMedium) // check that it contains exactly the same object instance
assertSize(14, result.supportedJobs)
assertSize(33, result.jobsRequiringTan)
result.accounts.forEach { account ->
assertTrue(account.allowedJobs.isNotEmpty())
assertContains(result.supportedJobs, account.allowedJobs) // check that it contains exactly the same object instances
}
assertEquals(serializedBankData, json.encodeToString(result))
}
}

View file

@ -0,0 +1,52 @@
package net.codinux.banking.fints.serialization
import net.codinux.banking.fints.test.TestDataGenerator
import net.codinux.banking.fints.test.assertContains
import net.codinux.banking.fints.test.assertSize
import net.codinux.banking.fints.test.assertTrue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class FinTsModelSerializerTest {
private val serializedBankData = TestDataGenerator.serializedBankData
@Test
fun serializeToJson() {
val bank = TestDataGenerator.generateBankDataForSerialization()
val result = FinTsModelSerializer.serializeToJson(bank, true)
assertEquals(serializedBankData, result)
}
@Test
fun deserializeFromJson() {
val result = FinTsModelSerializer.deserializeFromJson(serializedBankData)
assertNotNull(result)
assertSize(8, result.tanMethodsSupportedByBank)
assertSize(4, result.tanMethodsAvailableForUser)
assertContains(result.tanMethodsSupportedByBank, result.tanMethodsAvailableForUser) // check that it contains exactly the same object instances
assertNotNull(result.selectedTanMethod)
assertContains(result.tanMethodsSupportedByBank, result.selectedTanMethod) // check that it contains exactly the same object instance
assertSize(3, result.tanMedia)
assertNotNull(result.selectedTanMedium)
assertContains(result.tanMedia, result.selectedTanMedium) // check that it contains exactly the same object instance
assertSize(14, result.supportedJobs)
assertSize(33, result.jobsRequiringTan)
result.accounts.forEach { account ->
assertTrue(account.allowedJobs.isNotEmpty())
assertContains(result.supportedJobs, account.allowedJobs) // check that it contains exactly the same object instances
}
assertEquals(serializedBankData, FinTsModelSerializer.serializeToJson(result, true))
}
}

View file

@ -61,6 +61,12 @@ fun <T : Any?> assertContains(collection: Collection<T>, vararg items: T) {
}
}
fun <T : Any?> assertContains(collection: Collection<T>, items: Collection<T>) {
items.forEach { item ->
kotlin.test.assertContains(collection, item)
}
}
inline fun <reified T : Throwable> assertThrows(action: () -> Unit) {
try {

View file

@ -0,0 +1,539 @@
package net.codinux.banking.fints.test
import net.codinux.banking.fints.messages.MessageBuilder
import net.codinux.banking.fints.messages.datenelemente.abgeleiteteformate.Laenderkennzeichen
import net.codinux.banking.fints.messages.datenelemente.implementierte.Dialogsprache
import net.codinux.banking.fints.messages.datenelemente.implementierte.HbciVersion
import net.codinux.banking.fints.messages.datenelemente.implementierte.KundensystemStatusWerte
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMediumStatus
import net.codinux.banking.fints.model.AccountData
import net.codinux.banking.fints.model.AccountFeature
import net.codinux.banking.fints.model.BankData
import net.codinux.banking.fints.model.mapper.ModelMapper
import net.codinux.banking.fints.response.InstituteSegmentId
import net.codinux.banking.fints.response.ResponseParser
import net.codinux.banking.fints.response.segments.AccountType
import net.codinux.banking.fints.response.segments.TanInfo
import net.codinux.banking.fints.response.segments.TanMediaList
object TestDataGenerator {
private val bankCode = "10010010"
private val bic = "ABCDDEBBXXX"
private val bankName = "Abzockbank"
private val serverAddress = "https://abzockbank.de/fints"
private val bpd = 17
private val customerId = "SuperUser"
private val password = "Liebe"
private val customerName = "Monika Superfrau"
private val upd = 27
fun generateBankDataForSerialization(): BankData {
val parser = ResponseParser()
val mapper = ModelMapper(MessageBuilder())
val bankResponse = parser.parse("""
HIRMS:5:2:4+3050::BPD nicht mehr aktuell, aktuelle Version enthalten.+3920::Zugelassene Zwei-Schritt-Verfahren für den Benutzer.:910:911:912:913+0020::Der Auftrag wurde ausgeführt.'
HISALS:145:5:4+1+1'
HISALS:12:8:4+1+1+0+J'
HIKAZS:123:5:4+1+1+360:J:N'
HICCSS:96:1:4+1+1+0'
HIIPZS:22:1:4+1+1+0+;:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09'
DIKKUS:67:2:4+1+1+0+90:N:J'
HITABS:153:4:4+1+1+0'
HITAUS:154:1:4+1+1+0+N:N:J'
HITANS:169:6:4+1+1+1+J:N:0:910:2:HHD1.3.0:::chipTAN manuell:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:911:2:HHD1.3.2OPT:HHDOPT1:1.3.2:chipTAN optisch:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:912:2:HHD1.3.2USB:HHDUSB1:1.3.2:chipTAN-USB:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:913:2:Q1S:Secoder_UC:1.2.0:chipTAN-QR:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:920:2:smsTAN:::smsTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:2:N:5:921:2:pushTAN:::pushTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:2:N:2:900:2:iTAN:::iTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:0'
HITANS:170:7:4+1+1+1+N:N:0:922:2:pushTAN-dec:Decoupled::pushTAN 2.0:::Aufforderung:2048:J:2:N:0:0:N:N:00:2:N:2:180:1:1:J:J:923:2:pushTAN-cas:Decoupled::pushTAN 2.0:::Aufforderung:2048:J:2:N:0:0:N:N:00:2:N:5:180:1:1:J:J'
HITAB:5:4:3+1+G:2:1234567890::::::::::SparkassenCard (Debitkarte)::::::::+G:1:1234567891::::::::::SparkassenCard (Debitkarte)::::::::'
HITAB:5:4:3+0+A:1:::::::::::Alle Geräte::::::::'
HIPINS:78:1:3+1+1+0+5:20:6:VR-NetKey oder Alias::HKTAN:N:HKKAZ:J:HKSAL:N:HKEKA:N:HKPAE:J:HKPSP:N:HKQTG:N:HKCSA:J:HKCSB:N:HKCSL:J:HKCCS:J:HKSPA:N:HKDSE:J:HKBSE:J:HKBME:J:HKCDL:J:HKPPD:J:HKCDN:J:HKDSB:N:HKCUB:N:HKDSW:J:HKAUB:J:HKBBS:N:HKDMB:N:HKDBS:N:HKBMB:N:HKECA:N:HKCMB:N:HKCME:J:HKCML:J:HKWDU:N:HKWPD:N:HKDME:J:HKCCM:J:HKCDB:N:HKCDE:J:HKCSE:J:HKCUM:J:HKKAU:N:HKKIF:N:HKBAZ:N:HKZDF:J:HKCAZ:J:HKDDB:N:HKDDE:J:HKDDL:J:HKDDN:J:HKKAA:N:HKPOF:N:HKIPS:N:HKIPZ:J:HKBML:J:HKBSA:J:HKBSL:J:HKDML:J:HKDSA:J:HKDSL:J:HKZDA:J:HKZDL:N:GKVPU:N:GKVPD:N'
HISPAS:42:1:4+20+1+0+N:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02'
HISPAS:43:2:4+20+1+0+N:N:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02'
HISPAS:44:3:4+20+1+0+N:N:N:N:0:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02'
""".trimIndent().replace("\n", "").replace("\r", ""))
val tanMethods = bankResponse.getSegmentsById<TanInfo>(InstituteSegmentId.TanInfo).flatMap { mapper.mapToTanMethods(it) }
val tanMedia = bankResponse.getSegmentsById<TanMediaList>(InstituteSegmentId.TanMediaList).flatMap { it.tanMedia }
return BankData(
bankCode, customerId, password, serverAddress, bic, bankName, Laenderkennzeichen.Germany, bpd,
customerId, customerName, upd,
tanMethods, tanMethods.filter { it.securityFunction.code.startsWith("91") }, tanMethods.first { it.securityFunction.code == "912" },
tanMedia, tanMedia.first { it.status == TanMediumStatus.Aktiv },
listOf(Dialogsprache.German, Dialogsprache.English), Dialogsprache.German, "47", KundensystemStatusWerte.Benoetigt,
1, listOf(HbciVersion.FinTs_3_0_0)
).apply {
this.addAccount(createCheckingAccount(this, listOf("HKSAL", "HKKAZ", "HKCCS", "HKIPZ")))
this.addAccount(createCreditCardAccount(this, listOf("DKKKU")))
mapper.updateBankData(this, bankResponse)
this.jobsRequiringTan = this.jobsRequiringTan.sorted().toSet() // sort them for comparability in tests
}
}
private fun createCheckingAccount(bank: BankData, allowedJobNames: List<String>) =
createAccount(bank, AccountType.Girokonto, "12345678", "Kontokorrent", allowedJobNames, AccountFeature.entries)
private fun createCreditCardAccount(bank: BankData, allowedJobNames: List<String>) =
createAccount(bank, AccountType.Kreditkartenkonto, "4321876521096543", "Visa-Card", allowedJobNames, listOf(
AccountFeature.RetrieveAccountTransactions))
private fun createAccount(bank: BankData, type: AccountType, accountIdentifier: String, productName: String? = null, allowedJobNames: List<String>, features: Collection<AccountFeature> = emptyList(), subAccountAttribute: String? = null) = AccountData(
accountIdentifier, subAccountAttribute, Laenderkennzeichen.Germany, bankCode, "DE11${bankCode}$accountIdentifier", customerId, type, "EUR", customerName, productName, "T:1000,:EUR", allowedJobNames, bank.supportedJobs.filter { allowedJobNames.contains(it.jobName) }
).apply {
this.serverTransactionsRetentionDays = 270
features.forEach { feature ->
this.setSupportsFeature(feature, true)
}
}
val serializedBankData = """
{
"bankCode": "10010010",
"customerId": "SuperUser",
"pin": "Liebe",
"finTs3ServerAddress": "https://abzockbank.de/fints",
"bic": "ABCDDEBBXXX",
"bankName": "Abzockbank",
"countryCode": 280,
"bpdVersion": 17,
"userId": "SuperUser",
"customerName": "Monika Superfrau",
"updVersion": 27,
"tanMethodsSupportedByBank": [
{
"displayName": "chipTAN manuell",
"securityFunction": "PIN_TAN_910",
"type": "ChipTanManuell",
"hhdVersion": "HHD_1_3",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric"
},
{
"displayName": "chipTAN optisch",
"securityFunction": "PIN_TAN_911",
"type": "ChipTanFlickercode",
"hhdVersion": "HHD_1_3",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric"
},
{
"displayName": "chipTAN-USB",
"securityFunction": "PIN_TAN_912",
"type": "ChipTanUsb",
"hhdVersion": "HHD_1_3",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric"
},
{
"displayName": "chipTAN-QR",
"securityFunction": "PIN_TAN_913",
"type": "ChipTanQrCode",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric"
},
{
"displayName": "smsTAN",
"securityFunction": "PIN_TAN_920",
"type": "SmsTan",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric",
"nameOfTanMediumRequired": true
},
{
"displayName": "pushTAN",
"securityFunction": "PIN_TAN_921",
"type": "AppTan",
"maxTanInputLength": 6,
"allowedTanFormat": "Numeric",
"nameOfTanMediumRequired": true
},
{
"displayName": "pushTAN 2.0",
"securityFunction": "PIN_TAN_922",
"type": "DecoupledTan",
"nameOfTanMediumRequired": true,
"hktanVersion": 7,
"decoupledParameters": {
"manualConfirmationAllowed": true,
"periodicStateRequestsAllowed": true,
"maxNumberOfStateRequests": 180,
"initialDelayInSecondsForStateRequest": 1,
"delayInSecondsForNextStateRequest": 1
}
},
{
"displayName": "pushTAN 2.0",
"securityFunction": "PIN_TAN_923",
"type": "DecoupledTan",
"nameOfTanMediumRequired": true,
"hktanVersion": 7,
"decoupledParameters": {
"manualConfirmationAllowed": true,
"periodicStateRequestsAllowed": true,
"maxNumberOfStateRequests": 180,
"initialDelayInSecondsForStateRequest": 1,
"delayInSecondsForNextStateRequest": 1
}
}
],
"identifierOfTanMethodsAvailableForUser": [
"910",
"911",
"912",
"913"
],
"selectedTanMethodIdentifier": "912",
"tanMedia": [
{
"mediumClass": "TanGenerator",
"status": "Verfuegbar",
"mediumName": "SparkassenCard (Debitkarte)",
"tanGenerator": {
"cardNumber": "1234567890",
"cardSequenceNumber": null,
"cardType": null,
"validFrom": null,
"validTo": null
}
},
{
"mediumClass": "TanGenerator",
"status": "Aktiv",
"mediumName": "SparkassenCard (Debitkarte)",
"tanGenerator": {
"cardNumber": "1234567891",
"cardSequenceNumber": null,
"cardType": null,
"validFrom": null,
"validTo": null
}
},
{
"mediumClass": "AlleMedien",
"status": "Aktiv",
"mediumName": "Alle Geräte"
}
],
"selectedTanMediumIdentifier": "TanGenerator SparkassenCard (Debitkarte) Aktiv 1234567891 null",
"supportedLanguages": [
"German",
"English"
],
"selectedLanguage": "German",
"customerSystemId": "47",
"customerSystemStatus": "Benoetigt",
"countMaxJobsPerMessage": 1,
"supportedHbciVersions": [
"FinTs_3_0_0"
],
"supportedJobs": [
{
"jobName": "HKSAL",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": null,
"segmentId": "HISALS",
"segmentNumber": 145,
"segmentVersion": 5,
"segmentString": "HISALS:145:5:4+1+1"
},
{
"jobName": "HKSAL",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HISALS",
"segmentNumber": 12,
"segmentVersion": 8,
"segmentString": "HISALS:12:8:4+1+1+0+J"
},
{
"jobName": "HKCCS",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HICCSS",
"segmentNumber": 96,
"segmentVersion": 1,
"segmentString": "HICCSS:96:1:4+1+1+0"
},
{
"jobName": "HKIPZ",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HIIPZS",
"segmentNumber": 22,
"segmentVersion": 1,
"segmentString": "HIIPZS:22:1:4+1+1+0+;:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.09"
},
{
"jobName": "HKTAB",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HITABS",
"segmentNumber": 153,
"segmentVersion": 4,
"segmentString": "HITABS:153:4:4+1+1+0"
},
{
"jobName": "HKTAN",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 1,
"segmentId": "HITANS",
"segmentNumber": 169,
"segmentVersion": 6,
"segmentString": "HITANS:169:6:4+1+1+1+J:N:0:910:2:HHD1.3.0:::chipTAN manuell:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:911:2:HHD1.3.2OPT:HHDOPT1:1.3.2:chipTAN optisch:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:912:2:HHD1.3.2USB:HHDUSB1:1.3.2:chipTAN-USB:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:913:2:Q1S:Secoder_UC:1.2.0:chipTAN-QR:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:1:920:2:smsTAN:::smsTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:2:N:5:921:2:pushTAN:::pushTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:2:N:2:900:2:iTAN:::iTAN:6:1:TAN-Nummer:3:J:2:N:0:0:N:N:00:0:N:0"
},
{
"jobName": "HKTAN",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 1,
"segmentId": "HITANS",
"segmentNumber": 170,
"segmentVersion": 7,
"segmentString": "HITANS:170:7:4+1+1+1+N:N:0:922:2:pushTAN-dec:Decoupled::pushTAN 2.0:::Aufforderung:2048:J:2:N:0:0:N:N:00:2:N:2:180:1:1:J:J:923:2:pushTAN-cas:Decoupled::pushTAN 2.0:::Aufforderung:2048:J:2:N:0:0:N:N:00:2:N:5:180:1:1:J:J"
},
{
"jobName": "HKPIN",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HIPINS",
"segmentNumber": 78,
"segmentVersion": 1,
"segmentString": "HIPINS:78:1:3+1+1+0+5:20:6:VR-NetKey oder Alias::HKTAN:N:HKKAZ:J:HKSAL:N:HKEKA:N:HKPAE:J:HKPSP:N:HKQTG:N:HKCSA:J:HKCSB:N:HKCSL:J:HKCCS:J:HKSPA:N:HKDSE:J:HKBSE:J:HKBME:J:HKCDL:J:HKPPD:J:HKCDN:J:HKDSB:N:HKCUB:N:HKDSW:J:HKAUB:J:HKBBS:N:HKDMB:N:HKDBS:N:HKBMB:N:HKECA:N:HKCMB:N:HKCME:J:HKCML:J:HKWDU:N:HKWPD:N:HKDME:J:HKCCM:J:HKCDB:N:HKCDE:J:HKCSE:J:HKCUM:J:HKKAU:N:HKKIF:N:HKBAZ:N:HKZDF:J:HKCAZ:J:HKDDB:N:HKDDE:J:HKDDL:J:HKDDN:J:HKKAA:N:HKPOF:N:HKIPS:N:HKIPZ:J:HKBML:J:HKBSA:J:HKBSL:J:HKDML:J:HKDSA:J:HKDSL:J:HKZDA:J:HKZDL:N:GKVPU:N:GKVPD:N"
}
],
"supportedDetailedJobs": [
{
"type": "RetrieveAccountTransactionsParameters",
"jobParameters": {
"jobName": "HKKAZ",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": null,
"segmentId": "HIKAZS",
"segmentNumber": 123,
"segmentVersion": 5,
"segmentString": "HIKAZS:123:5:4+1+1+360:J:N"
},
"serverTransactionsRetentionDays": 360,
"settingCountEntriesAllowed": true,
"settingAllAccountAllowed": false
},
{
"type": "RetrieveAccountTransactionsParameters",
"jobParameters": {
"jobName": "DKKKU",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "DIKKUS",
"segmentNumber": 67,
"segmentVersion": 2,
"segmentString": "DIKKUS:67:2:4+1+1+0+90:N:J"
},
"serverTransactionsRetentionDays": 90,
"settingCountEntriesAllowed": false,
"settingAllAccountAllowed": true
},
{
"type": "ChangeTanMediaParameters",
"jobParameters": {
"jobName": "HKTAU",
"maxCountJobs": 1,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HITAUS",
"segmentNumber": 154,
"segmentVersion": 1,
"segmentString": "HITAUS:154:1:4+1+1+0+N:N:J"
},
"enteringTanListNumberRequired": false,
"enteringCardSequenceNumberRequired": false,
"enteringAtcAndTanRequired": true,
"enteringCardTypeAllowed": false,
"accountInfoRequired": false,
"allowedCardTypes": [
]
},
{
"type": "SepaAccountInfoParameters",
"jobParameters": {
"jobName": "HKSPA",
"maxCountJobs": 20,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HISPAS",
"segmentNumber": 42,
"segmentVersion": 1,
"segmentString": "HISPAS:42:1:4+20+1+0+N:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02"
},
"retrieveSingleAccountAllowed": false,
"nationalAccountRelationshipAllowed": false,
"structuredReferenceAllowed": false,
"settingMaxAllowedEntriesAllowed": false,
"countReservedReferenceLength": 0,
"supportedSepaFormats": [
"urn:iso:std:iso:20022:tech:xsd:pain.001.003.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.003.02",
"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02"
]
},
{
"type": "SepaAccountInfoParameters",
"jobParameters": {
"jobName": "HKSPA",
"maxCountJobs": 20,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HISPAS",
"segmentNumber": 43,
"segmentVersion": 2,
"segmentString": "HISPAS:43:2:4+20+1+0+N:N:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02"
},
"retrieveSingleAccountAllowed": false,
"nationalAccountRelationshipAllowed": false,
"structuredReferenceAllowed": false,
"settingMaxAllowedEntriesAllowed": false,
"countReservedReferenceLength": 0,
"supportedSepaFormats": [
"urn:iso:std:iso:20022:tech:xsd:pain.001.003.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.003.02",
"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02"
]
},
{
"type": "SepaAccountInfoParameters",
"jobParameters": {
"jobName": "HKSPA",
"maxCountJobs": 20,
"minimumCountSignatures": 1,
"securityClass": 0,
"segmentId": "HISPAS",
"segmentNumber": 44,
"segmentVersion": 3,
"segmentString": "HISPAS:44:3:4+20+1+0+N:N:N:N:0:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.003.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.003.02:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03:urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.008.001.02"
},
"retrieveSingleAccountAllowed": false,
"nationalAccountRelationshipAllowed": false,
"structuredReferenceAllowed": false,
"settingMaxAllowedEntriesAllowed": false,
"countReservedReferenceLength": 0,
"supportedSepaFormats": [
"urn:iso:std:iso:20022:tech:xsd:pain.001.003.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.003.02",
"urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02"
]
}
],
"jobsRequiringTan": [
"HKAUB",
"HKBME",
"HKBML",
"HKBSA",
"HKBSE",
"HKBSL",
"HKCAZ",
"HKCCM",
"HKCCS",
"HKCDE",
"HKCDL",
"HKCDN",
"HKCME",
"HKCML",
"HKCSA",
"HKCSE",
"HKCSL",
"HKCUM",
"HKDDE",
"HKDDL",
"HKDDN",
"HKDME",
"HKDML",
"HKDSA",
"HKDSE",
"HKDSL",
"HKDSW",
"HKIPZ",
"HKKAZ",
"HKPAE",
"HKPPD",
"HKZDA",
"HKZDF"
],
"pinInfo": {
"minPinLength": 5,
"maxPinLength": 20,
"minTanLength": 6,
"userIdHint": "VR-NetKey oder Alias",
"customerIdHint": null
},
"accounts": [
{
"accountIdentifier": "12345678",
"subAccountAttribute": null,
"bankCountryCode": 280,
"bankCode": "10010010",
"iban": "DE111001001012345678",
"customerId": "SuperUser",
"accountType": "Girokonto",
"currency": "EUR",
"accountHolderName": "Monika Superfrau",
"productName": "Kontokorrent",
"accountLimit": "T:1000,:EUR",
"allowedJobNames": [
"HKSAL",
"HKKAZ",
"HKCCS",
"HKIPZ"
],
"serverTransactionsRetentionDays": 270,
"supportedFeatures": [
"RetrieveBalance",
"RetrieveAccountTransactions",
"TransferMoney",
"RealTimeTransfer"
]
},
{
"accountIdentifier": "4321876521096543",
"subAccountAttribute": null,
"bankCountryCode": 280,
"bankCode": "10010010",
"iban": "DE11100100104321876521096543",
"customerId": "SuperUser",
"accountType": "Kreditkartenkonto",
"currency": "EUR",
"accountHolderName": "Monika Superfrau",
"productName": "Visa-Card",
"accountLimit": "T:1000,:EUR",
"allowedJobNames": [
"DKKKU"
],
"serverTransactionsRetentionDays": 270,
"supportedFeatures": [
"RetrieveAccountTransactions"
]
}
],
"modelVersion": "0.6.0"
}
""".trimIndent()
}

View file

@ -22,8 +22,19 @@ class Mt535ParserTest {
assertSize(2, statement.holdings)
assertHolding(statement.holdings.first(), "MUL AMUN MSCI WLD ETF ACC MUL Amundi MSCI World V", "LU1781541179", null, LocalDate(2024, 6, 3), 1693, "16,828250257", "18531,08")
assertHolding(statement.holdings[1], "NVIDIA CORP. DL-,001 NVIDIA Corp.", "US67066G1040", null, LocalDate(2024, 8, 5), 214, "92,86", "22846,64")
assertHolding(statement.holdings.first(), "MUL AMUN MSCI WLD ETF ACC MUL Amundi MSCI World V", "LU1781541179", null, LocalDate(2024, 6, 3), 1693.0, "16,828250257", "18531,08")
assertHolding(statement.holdings[1], "NVIDIA CORP. DL-,001 NVIDIA Corp.", "US67066G1040", null, LocalDate(2024, 8, 5), 214.0, "92,86", "22846,64")
}
@Test
fun quantityIsFloatingPointNumber() {
val result = underTest.parseMt535String(QuantityIsFloatingPointNumber)
val statement = assertStatement(result, "70033100", "0123456789", "21480,38", LocalDate(2024, 10, 15), LocalDate(2024, 10, 15))
assertSize(1, statement.holdings)
assertHolding(statement.holdings.first(), "SAP SE O.N. SAP SE", "DE0007164600", null, LocalDate(2024, 10, 4), 100.446, "199,090257451", "21480,38")
}
@Test
@ -46,8 +57,8 @@ class Mt535ParserTest {
assertSize(3, statement.holdings)
assertHolding(statement.holdings.first(), "/DE/123456 Mustermann AG, Stammaktien", "DE0123456789", null, LocalDate(1999, 8, 15), 100, "68,5", "5270,")
assertHolding(statement.holdings[1], "/DE/123457 Mustermann AG, Vorzugsaktien", "DE0123456790", null, LocalDate(1998, 10, 13), 100, "42,75", "5460,")
assertHolding(statement.holdings.first(), "/DE/123456 Mustermann AG, Stammaktien", "DE0123456789", null, LocalDate(1999, 8, 15), 100.0, "68,5", "5270,")
assertHolding(statement.holdings[1], "/DE/123457 Mustermann AG, Vorzugsaktien", "DE0123456790", null, LocalDate(1998, 10, 13), 100.0, "42,75", "5460,")
// TODO: these values are not correct. Implement taking foreign currencies into account to fix this
assertHolding(statement.holdings[2], "Australian Domestic Bonds 1993 (2003) Ser. 10", "AU9876543210", null, LocalDate(1999, 3, 15), null, "31", "6294,65")
}
@ -71,7 +82,7 @@ class Mt535ParserTest {
return statement
}
private fun assertHolding(holding: Holding, name: String, isin: String?, wkn: String?, buyingDate: LocalDate?, quantity: Int?, averagePrice: String?, balance: String?, currency: String? = "EUR") {
private fun assertHolding(holding: Holding, name: String, isin: String?, wkn: String?, buyingDate: LocalDate?, quantity: Double?, averagePrice: String?, balance: String?, currency: String? = "EUR") {
assertEquals(name, holding.name)
assertEquals(isin, holding.isin)
@ -140,6 +151,41 @@ class Mt535ParserTest {
-
""".trimIndent()
private val QuantityIsFloatingPointNumber = """
:16R:GENL
:28E:1/ONLY
:20C::SEME//NONREF
:23G:NEWM
:98A::PREP//20241015
:98A::STAT//20241015
:22F::STTY//CUST
:97A::SAFE//70033100/0123456789
:17B::ACTI//Y
:16S:GENL
:16R:FIN
:35B:ISIN DE0007164600
SAP SE O.N.
SAP SE
:93B::AGGR//UNIT/100,446
:16R:SUBBAL
:93C::TAVI//UNIT/AVAI/100,446
:70C::SUBB//1 SAP SE O.N.
2
3 EDE 213.850000000EUR 2024-10-15T09:03:25.4
4 19997.82EUR DE0007164600, 1/SHS
:16S:SUBBAL
:19A::HOLD//EUR21480,38
:70E::HOLD//1STK++++20241004+
2199,090257451+EUR
:16S:FIN
:16R:ADDINFO
:19A::HOLP//EUR21480,38
:16S:ADDINFO
-'
""".trimIndent()
/**
* See Anlage_3_Datenformate_V3.8, S. 317ff
*

View file

@ -31,7 +31,7 @@ class ResponseParserTestJvm : FinTsTestBaseJvm() {
val decodedChallengeHhdUc = TanImageDecoder().decodeChallenge(result.tanResponse?.challengeHHD_UC ?: "")
assertThat(decodedChallengeHhdUc.decodingSuccessful).isTrue()
assertThat(decodedChallengeHhdUc.imageBytes.size).isEqualTo(3664)
assertThat(decodedChallengeHhdUc.imageBytes?.size).isEqualTo(3664)
}
}