Compare commits

...

10 Commits

12 changed files with 154 additions and 26 deletions

View File

@ -1,6 +1,6 @@
// TODO: move to versions.gradle // TODO: move to versions.gradle
ext { ext {
appVersionName = '1.0.0-Alpha-11-SNAPSHOT' appVersionName = '1.0.0-Alpha-12-SNAPSHOT'
/* Test */ /* Test */
@ -16,6 +16,7 @@ ext {
buildscript { buildscript {
repositories { repositories {
mavenCentral() mavenCentral()
maven { url = "https://maven.dankito.net/api/packages/codinux/maven" }
google() google()
} }
@ -47,4 +48,10 @@ task publishAllToMavenLocal {
dependsOn = [ dependsOn = [
"fints4k:publishToMavenLocal", "fints4k:publishToMavenLocal",
] ]
}
task publishAll {
dependsOn = [
"fints4k:publish",
]
} }

View File

@ -64,13 +64,13 @@ kotlin {
dependencies { dependencies {
api("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion") api("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
implementation("net.codinux.log:kmp-log:$klfVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinxSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$kotlinxSerializationVersion")
implementation("net.codinux.log:kmp-log:$klfVersion")
} }
} }
@ -135,4 +135,19 @@ kotlin {
} }
} }
}
publishing {
repositories {
maven {
name = "codinux"
url = uri("https://maven.dankito.net/api/packages/codinux/maven")
credentials(PasswordCredentials) {
username = project.property("codinuxRegistryWriterUsername")
password = project.property("codinuxRegistryWriterPassword")
}
}
}
} }

View File

@ -9,11 +9,13 @@ import net.dankito.banking.fints.model.MessageLogEntryType
import net.dankito.banking.fints.extensions.getInnerException import net.dankito.banking.fints.extensions.getInnerException
import net.dankito.banking.fints.extensions.nthIndexOf import net.dankito.banking.fints.extensions.nthIndexOf
import net.dankito.banking.fints.extensions.toStringWithMinDigits import net.dankito.banking.fints.extensions.toStringWithMinDigits
import net.dankito.banking.fints.util.FinTsUtils
import kotlin.reflect.KClass import kotlin.reflect.KClass
open class MessageLogCollector( open class MessageLogCollector(
private val options: FinTsClientOptions = FinTsClientOptions() private val options: FinTsClientOptions = FinTsClientOptions(),
private val finTsUtils: FinTsUtils = FinTsUtils()
) { ) {
companion object { companion object {
@ -22,7 +24,7 @@ open class MessageLogCollector(
const val MaxCountStackTraceElements = 15 const val MaxCountStackTraceElements = 15
private const val NewLine = "\r\n" internal const val NewLine = "\r\n"
private val log by logger() private val log by logger()
} }
@ -39,7 +41,7 @@ open class MessageLogCollector(
val message = if (logEntry.type == MessageLogEntryType.Error) { val message = if (logEntry.type == MessageLogEntryType.Error) {
logEntry.messageTrace + logEntry.message + (if (logEntry.error != null) NewLine + getStackTrace(logEntry.error!!) else "") logEntry.messageTrace + logEntry.message + (if (logEntry.error != null) NewLine + getStackTrace(logEntry.error!!) else "")
} else { } else {
logEntry.messageTrace + "\n" + prettyPrintHbciMessage(logEntry.message) logEntry.messageTrace + "\n" + prettyPrintFinTsMessage(logEntry.message)
} }
return if (options.removeSensitiveDataFromMessageLog) { return if (options.removeSensitiveDataFromMessageLog) {
@ -55,7 +57,7 @@ open class MessageLogCollector(
addMessageLogEntry(type, context, messageTrace, message) addMessageLogEntry(type, context, messageTrace, message)
log.debug { "$messageTrace\n${prettyPrintHbciMessage(message)}" } log.debug { "$messageTrace\n${prettyPrintFinTsMessage(message)}" }
} }
open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Exception? = null) { open fun logError(loggingClass: KClass<*>, message: String, context: MessageContext, e: Exception? = null) {
@ -92,9 +94,8 @@ open class MessageLogCollector(
} }
} }
protected open fun prettyPrintHbciMessage(message: String): String { protected open fun prettyPrintFinTsMessage(message: String): String =
return message.replace("'", "'$NewLine") finTsUtils.prettyPrintFinTsMessage(message)
}
protected open fun safelyRemoveSensitiveDataFromMessage(message: String, bank: BankData?): String { protected open fun safelyRemoveSensitiveDataFromMessage(message: String, bank: BankData?): String {

View File

@ -230,9 +230,10 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
if (result.isJobVersionSupported) { if (result.isJobVersionSupported) {
val segmentNumber = SignedMessagePayloadFirstSegmentNumber val segmentNumber = SignedMessagePayloadFirstSegmentNumber
val balanceJob = if (result.isAllowed(5)) SaldenabfrageVersion5(segmentNumber, account) val balanceJob = if (result.isAllowed(6)) SaldenabfrageVersion6(segmentNumber, account)
// TODO: what about HKSAL6? else if (result.isAllowed(7)) SaldenabfrageVersion7(segmentNumber, account, context.bank)
else SaldenabfrageVersion7(segmentNumber, account, context.bank) else if (result.isAllowed(8)) SaldenabfrageVersion8(segmentNumber, account, context.bank)
else SaldenabfrageVersion5(segmentNumber, account)
val segments = mutableListOf<Segment>(balanceJob) val segments = mutableListOf<Segment>(balanceJob)
@ -249,7 +250,7 @@ open class MessageBuilder(protected val utils: FinTsUtils = FinTsUtils()) {
} }
protected open fun supportsGetBalanceMessage(account: AccountData): MessageBuilderResult { protected open fun supportsGetBalanceMessage(account: AccountData): MessageBuilderResult {
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.Balance, account, listOf(5, 7)) return getSupportedVersionsOfJobForAccount(CustomerSegmentId.Balance, account, listOf(5, 6, 7, 8))
} }

View File

@ -9,9 +9,9 @@ import net.dankito.banking.fints.messages.segmente.id.ISegmentId
open class Segmentkopf( open class Segmentkopf(
identifier: String, val identifier: String,
segmentVersion: Int, val segmentVersion: Int,
segmentNumber: Int = 0, val segmentNumber: Int = 0,
bezugssegment: Int? = null bezugssegment: Int? = null
) : Datenelementgruppe(listOf( ) : Datenelementgruppe(listOf(
@ -22,4 +22,6 @@ open class Segmentkopf(
constructor(id: ISegmentId, segmentVersion: Int, segmentNumber: Int) : this(id.id, segmentVersion, segmentNumber) constructor(id: ISegmentId, segmentVersion: Int, segmentNumber: Int) : this(id.id, segmentVersion, segmentNumber)
override fun toString() = "$identifier:$segmentNumber:$segmentVersion"
} }

View File

@ -4,6 +4,7 @@ import net.dankito.banking.fints.messages.Nachrichtenteil
import net.dankito.banking.fints.messages.Separators import net.dankito.banking.fints.messages.Separators
import net.dankito.banking.fints.messages.datenelemente.DatenelementBase import net.dankito.banking.fints.messages.datenelemente.DatenelementBase
import net.dankito.banking.fints.messages.datenelemente.implementierte.DoNotPrintDatenelement import net.dankito.banking.fints.messages.datenelemente.implementierte.DoNotPrintDatenelement
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
abstract class Segment(val dataElementsAndGroups: List<DatenelementBase>) : Nachrichtenteil() { abstract class Segment(val dataElementsAndGroups: List<DatenelementBase>) : Nachrichtenteil() {
@ -28,4 +29,6 @@ abstract class Segment(val dataElementsAndGroups: List<DatenelementBase>) : Nach
return ReplaceEmptyDataElementGroupSeparatorsAtEndPattern.replaceFirst(formattedSegment, "") return ReplaceEmptyDataElementGroupSeparatorsAtEndPattern.replaceFirst(formattedSegment, "")
} }
override fun toString() = "${dataElementsAndGroups.firstOrNull { it is Segmentkopf }}"
} }

View File

@ -0,0 +1,14 @@
package net.dankito.banking.fints.messages.segmente.implementierte.umsaetze
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
import net.dankito.banking.fints.model.AccountData
open class SaldenabfrageVersion6(
segmentNumber: Int,
account: AccountData,
allAccounts: Boolean = false,
maxAmountEntries: Int? = null,
continuationId: String? = null
)
: SaldenabfrageBase(segmentNumber, 6, Kontoverbindung(account), allAccounts, maxAmountEntries, continuationId)

View File

@ -0,0 +1,16 @@
package net.dankito.banking.fints.messages.segmente.implementierte.umsaetze
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.account.KontoverbindungInternational
import net.dankito.banking.fints.model.AccountData
import net.dankito.banking.fints.model.BankData
open class SaldenabfrageVersion8(
segmentNumber: Int,
account: AccountData,
bank: BankData,
allAccounts: Boolean = false,
maxAmountEntries: Int? = null,
continuationId: String? = null
)
: SaldenabfrageBase(segmentNumber, 8, KontoverbindungInternational(account, bank), allAccounts, maxAmountEntries, continuationId)

View File

@ -248,6 +248,9 @@ open class ResponseParser(
// yes, by standard the Kontoinformation can be missing: // yes, by standard the Kontoinformation can be missing:
// N: bei Geschäftsvorfällen ohne Kontenbezug // N: bei Geschäftsvorfällen ohne Kontenbezug
// M: sonst // M: sonst
// ("Darüber hinaus kann auch ein Eintrag für nicht kontogebundene Geschäftsvorfälle (z. B. Informationsbestellung) eingestellt werden.
// Hierbei handelt es sich im Regelfall um Geschäftsvorfälle, die auch über den anonymen Zugang genutzt werden können. In diesem Fall
// sind die Felder für die Kontoverbindung und die übrigen kontobezogenen Angaben nicht zu belegen.")
// But in my eyes Deutsche Bank uses it wrong and adds a second HIUPD for the same account but with most information missing: // But in my eyes Deutsche Bank uses it wrong and adds a second HIUPD for the same account but with most information missing:
// HIUPD:7:6:4+++2200672485+++Christian+Dankl, Christian+++HKTAN:1+HKPRO:1+HKVVB:1+HKFRD:1+DKPSP:1+HKPSP:1' // HIUPD:7:6:4+++2200672485+++Christian+Dankl, Christian+++HKTAN:1+HKPRO:1+HKVVB:1+HKFRD:1+DKPSP:1+HKPSP:1'
return null return null
@ -262,8 +265,15 @@ open class ResponseParser(
val customerId = parseString(dataElementGroups[3]) val customerId = parseString(dataElementGroups[3])
val accountType = parseNullableCodeEnum(dataElementGroups[4], AccountTypeCode.values())?.type val accountType = parseNullableCodeEnum(dataElementGroups[4], AccountTypeCode.values())?.type
val currency = parseStringToNullIfEmpty(dataElementGroups[5]) val currency = parseStringToNullIfEmpty(dataElementGroups[5])
// Name Kontoinhaber 1 und 2
// Die Felder "Name des Kontoinhabers 1" und "Name des Kontoinhabers 2" sind in FinTS V3.0 mit ..27 Stellen definiert.
// Da diese Felder in anderem Kontext maximal 35 Stellen lang sein können, wird auch für diese beiden UPD-Felder eine
// Maximallänge von 35 Stellen zugelassen. Bestehende Implementierungen sollten damit keine Probleme bekommen und
// evtl. überzählige Stellen (>27) ggf. abschneiden.
val accountHolderName1 = parseString(dataElementGroups[6]) val accountHolderName1 = parseString(dataElementGroups[6])
val accountHolderName2 = if (dataElementGroups.size > 7) parseStringToNullIfEmpty(dataElementGroups[7]) else null val accountHolderName2 = if (dataElementGroups.size > 7) parseStringToNullIfEmpty(dataElementGroups[7]) else null
val productName = if (dataElementGroups.size > 8) parseStringToNullIfEmpty(dataElementGroups[8]) else null val productName = if (dataElementGroups.size > 8) parseStringToNullIfEmpty(dataElementGroups[8]) else null
val limit = if (dataElementGroups.size > 9) parseStringToNullIfEmpty(dataElementGroups[9]) else null // TODO: parse limit val limit = if (dataElementGroups.size > 9) parseStringToNullIfEmpty(dataElementGroups[9]) else null // TODO: parse limit
@ -440,11 +450,18 @@ open class ResponseParser(
} }
protected open fun mapToSingleTanMethodParameters(methodDataElements: List<String>): TanMethodParameters { protected open fun mapToSingleTanMethodParameters(methodDataElements: List<String>): TanMethodParameters {
val sicherheitsfunktion = try {
parseCodeEnum(methodDataElements[0], Sicherheitsfunktion.values())
} catch (e: Throwable) {
log.error { "Could not map Sicherheitsfuntion from value '${methodDataElements[0]}'" }
throw e
}
val dkTanMethod = tryToParseDkTanMethod(methodDataElements[3]) val dkTanMethod = tryToParseDkTanMethod(methodDataElements[3])
val isDecoupledTanMethod = dkTanMethod == DkTanMethod.Decoupled || dkTanMethod == DkTanMethod.DecoupledPush val isDecoupledTanMethod = dkTanMethod == DkTanMethod.Decoupled || dkTanMethod == DkTanMethod.DecoupledPush
return TanMethodParameters( return TanMethodParameters(
parseCodeEnum(methodDataElements[0], Sicherheitsfunktion.values()), sicherheitsfunktion,
parseCodeEnum(methodDataElements[1], TanProcess.values()), parseCodeEnum(methodDataElements[1], TanProcess.values()),
parseString(methodDataElements[2]), parseString(methodDataElements[2]),
dkTanMethod, dkTanMethod,
@ -649,17 +666,28 @@ open class ResponseParser(
protected open fun parseBalanceSegment(segment: String, dataElementGroups: List<String>): BalanceSegment { protected open fun parseBalanceSegment(segment: String, dataElementGroups: List<String>): BalanceSegment {
// dataElementGroups[1] is account details // 2: Kontoverbindung Auftraggeber (ktv, M), ab Version 7: Kontoverbindung international (kti, M)
// 3: Kontoproduktbezeichnung (an ..30, M)
// 4: Kontowährung (cur, M)
// 5: Gebuchter Saldo (btg, M)
// 6: Saldo der vorgemerkten Umsätze (btg, O)
// 7: Kreditlinie (btg, O)
// 8: Verfügbarer Betrag (btg, O)
// 9: Bereits verfügter Betrag (btg, O)
// 10: Überziehung (btg, O)
// 11: Buchungszeitpunkt (tsp, O)
// ab Version 7: 12: Fälligkeit (dat, O: bei Kreditkartenkonten, N: sonst)
// ab Version 8: 13: Ab Monatswechsel pfändbar (btg, O)
val balance = parseBalance(dataElementGroups[4]) val balance = parseBalance(dataElementGroups[4])
val balanceOfPreBookedTransactions = if (dataElementGroups.size > 5) parseBalanceToNullIfZeroOrNotSet(dataElementGroups[5]) else null val balanceOfPreBookedTransactions = if (dataElementGroups.size > 5) parseBalanceToNullIfZeroOrNotSet(dataElementGroups[5]) else null
return BalanceSegment( return BalanceSegment(
balance.amount, balance = balance.amount,
parseString(dataElementGroups[3]), currency = parseString(dataElementGroups[3]),
balance.date, date = balance.date,
parseString(dataElementGroups[2]), accountProductName = parseString(dataElementGroups[2]),
balanceOfPreBookedTransactions?.amount, balanceOfPreBookedTransactions = balanceOfPreBookedTransactions?.amount,
segment segment
) )
} }

View File

@ -34,4 +34,12 @@ open class AddAccountResponse(
open val retrievedData: List<RetrievedAccountData> open val retrievedData: List<RetrievedAccountData>
get() = retrievedTransactionsResponses.mapNotNull { it.retrievedData } get() = retrievedTransactionsResponses.mapNotNull { it.retrievedData }
override val messageLog: List<MessageLogEntry>
get() = buildList {
addAll(super.messageLog)
retrievedTransactionsResponses.forEach {
addAll(it.messageLog)
}
}
} }

View File

@ -5,12 +5,19 @@ import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import net.dankito.banking.fints.extensions.nowAtEuropeBerlin import net.dankito.banking.fints.extensions.nowAtEuropeBerlin
import net.dankito.banking.fints.extensions.todayAtEuropeBerlin import net.dankito.banking.fints.extensions.todayAtEuropeBerlin
import net.dankito.banking.fints.log.MessageLogCollector
import net.dankito.banking.fints.messages.datenelemente.abgeleiteteformate.Datum import net.dankito.banking.fints.messages.datenelemente.abgeleiteteformate.Datum
import net.dankito.banking.fints.messages.datenelemente.abgeleiteteformate.Uhrzeit import net.dankito.banking.fints.messages.datenelemente.abgeleiteteformate.Uhrzeit
open class FinTsUtils { open class FinTsUtils {
companion object {
private val NewLine = MessageLogCollector.NewLine
private val BreakableSegmentSeparatorsRegex = Regex("""'([A-Z])""")
}
open fun formatDateToday(): String { open fun formatDateToday(): String {
return formatDate(LocalDate.todayAtEuropeBerlin()) return formatDate(LocalDate.todayAtEuropeBerlin())
@ -46,6 +53,13 @@ open class FinTsUtils {
} }
open fun prettyPrintFinTsMessage(finTsMessage: String): String {
return finTsMessage
.replace(BreakableSegmentSeparatorsRegex, "'$NewLine$1")
.replace("@HNSHK:", "@${NewLine}HNSHK:")
}
protected open fun convertToInt(string: String): Int { protected open fun convertToInt(string: String): Int {
return string.toInt() return string.toInt()
} }

View File

@ -89,4 +89,23 @@ class FinTsUtilsTest {
assertEquals(182251, result) assertEquals(182251, result)
} }
@Test
fun prettyPrint() {
val result = underTest.prettyPrintFinTsMessage("""HNHBK:1:3+000000000392+300+0+1'HNVSK:998:3+PIN:1+998+1+1::0+1:20240821:022352+2:2:13:@8@ :5:1+280:10010010:UserName:V:0:0+0'HNVSD:999:1+@230@HNSHK:2:4+PIN:1+999+1265303553+1+1+1::0+1+1:20240821:022352+1:999:1+6:10:16+280:10010010:UserName:S:0:0'HKIDN:3:2+280:10010010+UserName+0+0'HKVVB:4:3+0+0+0+15E53C26816138699C7B6A3E8+1.0.0'HKSYN:5:3+0'HNSHA:6:2+1265303553++MyPassword''HNHBS:7:1+1'""")
assertEquals(result.replace("\r\n", "\n"), """
HNHBK:1:3+000000000392+300+0+1'
HNVSK:998:3+PIN:1+998+1+1::0+1:20240821:022352+2:2:13:@8@ :5:1+280:10010010:UserName:V:0:0+0'
HNVSD:999:1+@230@
HNSHK:2:4+PIN:1+999+1265303553+1+1+1::0+1+1:20240821:022352+1:999:1+6:10:16+280:10010010:UserName:S:0:0'
HKIDN:3:2+280:10010010+UserName+0+0'
HKVVB:4:3+0+0+0+15E53C26816138699C7B6A3E8+1.0.0'
HKSYN:5:3+0'
HNSHA:6:2+1265303553++MyPassword''
HNHBS:7:1+1'
""".trimIndent().replace("\r\n", "\n")
)
}
} }