Implemented retrieving credit card transactions

This commit is contained in:
dankito 2020-09-23 03:49:24 +02:00
parent d16450d46b
commit e0dbd00634
14 changed files with 184 additions and 16 deletions

View File

@ -42,7 +42,7 @@ open class FinTsClient(
) { ) {
companion object { companion object {
val SupportedAccountTypes = listOf(AccountType.Girokonto, AccountType.Festgeldkonto) val SupportedAccountTypes = listOf(AccountType.Girokonto, AccountType.Festgeldkonto, AccountType.Kreditkartenkonto)
val FindAccountTransactionsStartRegex = Regex("^HIKAZ:\\d:\\d:\\d\\+@\\d+@", RegexOption.MULTILINE) val FindAccountTransactionsStartRegex = Regex("^HIKAZ:\\d:\\d:\\d\\+@\\d+@", RegexOption.MULTILINE)
val FindAccountTransactionsEndRegex = Regex("^-'", RegexOption.MULTILINE) val FindAccountTransactionsEndRegex = Regex("^-'", RegexOption.MULTILINE)
@ -376,7 +376,7 @@ open class FinTsClient(
protected open fun getTransactionsAfterInitAndGetBalance(parameter: GetTransactionsParameter, dialogContext: DialogContext, protected open fun getTransactionsAfterInitAndGetBalance(parameter: GetTransactionsParameter, dialogContext: DialogContext,
balanceResponse: BankResponse, callback: (GetTransactionsResponse) -> Unit) { balanceResponse: BankResponse, callback: (GetTransactionsResponse) -> Unit) {
val balance: Money? = balanceResponse.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let { var balance: Money? = balanceResponse.getFirstSegmentById<BalanceSegment>(InstituteSegmentId.Balance)?.let {
Money(it.balance, it.currency) Money(it.balance, it.currency)
} }
val bookedTransactions = mutableSetOf<AccountTransaction>() val bookedTransactions = mutableSetOf<AccountTransaction>()
@ -397,6 +397,11 @@ open class FinTsClient(
parameter.retrievedChunkListener?.invoke(bookedTransactions) parameter.retrievedChunkListener?.invoke(bookedTransactions)
} }
response.getFirstSegmentById<ReceivedCreditCardTransactionsAndBalance>(InstituteSegmentId.CreditCardTransactions)?.let { transactionsSegment ->
balance = Money(transactionsSegment.balance.amount, transactionsSegment.balance.currency ?: "EUR")
bookedTransactions.addAll(transactionsSegment.transactions.map { AccountTransaction(parameter.account, it.amount, "", it.bookingDate, it.otherPartyName, null, null, "", it.valueDate) })
}
} }
getAndHandleResponseForMessage(message, dialogContext) { response -> getAndHandleResponseForMessage(message, dialogContext) { response ->

View File

@ -154,6 +154,12 @@ open class MessageBuilder(protected val generator: ISegmentNumberGenerator = Seg
return createGetTransactionsMessageMt940(result, parameter, dialogContext) return createGetTransactionsMessageMt940(result, parameter, dialogContext)
} }
val creditCardResult = supportsGetCreditCardTransactions(parameter.account)
if (creditCardResult.isJobVersionSupported) {
return createGetCreditCardTransactionsMessage(result, parameter, dialogContext)
}
return result return result
} }
@ -181,14 +187,29 @@ open class MessageBuilder(protected val generator: ISegmentNumberGenerator = Seg
.firstOrNull { it.settingCountEntriesAllowed } != null .firstOrNull { it.settingCountEntriesAllowed } != null
} }
protected open fun createGetCreditCardTransactionsMessage(result: MessageBuilderResult, parameter: GetTransactionsParameter,
dialogContext: DialogContext): MessageBuilderResult {
val segments = mutableListOf<Segment>(KreditkartenUmsaetze(generator.resetSegmentNumber(2), parameter))
addTanSegmentIfRequired(CustomerSegmentId.CreditCardTransactions, dialogContext, segments)
return createSignedMessageBuilderResult(dialogContext, segments)
}
open fun supportsGetTransactions(account: AccountData): Boolean { open fun supportsGetTransactions(account: AccountData): Boolean {
return supportsGetTransactionsMt940(account).isJobVersionSupported return supportsGetTransactionsMt940(account).isJobVersionSupported
|| supportsGetCreditCardTransactions(account).isJobVersionSupported
} }
protected open fun supportsGetTransactionsMt940(account: AccountData): MessageBuilderResult { protected open fun supportsGetTransactionsMt940(account: AccountData): MessageBuilderResult {
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.AccountTransactionsMt940, account, listOf(5, 6, 7)) return getSupportedVersionsOfJobForAccount(CustomerSegmentId.AccountTransactionsMt940, account, listOf(5, 6, 7))
} }
protected open fun supportsGetCreditCardTransactions(account: AccountData): MessageBuilderResult {
return getSupportedVersionsOfJobForAccount(CustomerSegmentId.CreditCardTransactions, account, listOf(2))
}
open fun createGetBalanceMessage(account: AccountData, dialogContext: DialogContext): MessageBuilderResult { open fun createGetBalanceMessage(account: AccountData, dialogContext: DialogContext): MessageBuilderResult {

View File

@ -2,10 +2,15 @@ package net.dankito.banking.fints.messages.datenelemente.implementierte.account
import net.dankito.banking.fints.messages.Existenzstatus import net.dankito.banking.fints.messages.Existenzstatus
import net.dankito.banking.fints.messages.datenelemente.basisformate.NumerischesDatenelement import net.dankito.banking.fints.messages.datenelemente.basisformate.NumerischesDatenelement
import net.dankito.banking.fints.model.GetTransactionsParameter
/** /**
* Maximale Anzahl rückzumeldender Einträge bei Abholaufträgen, Kreditinstitutsangeboten * Maximale Anzahl rückzumeldender Einträge bei Abholaufträgen, Kreditinstitutsangeboten
* oder informationen (vgl. [Formals], Kap. B.6.3). * oder informationen (vgl. [Formals], Kap. B.6.3).
*/ */
open class MaximaleAnzahlEintraege(maxAmount: Int?, existenzstatus: Existenzstatus) : NumerischesDatenelement(maxAmount, 4, existenzstatus) open class MaximaleAnzahlEintraege(maxAmount: Int?, existenzstatus: Existenzstatus) : NumerischesDatenelement(maxAmount, 4, existenzstatus) {
constructor(parameter: GetTransactionsParameter) : this(parameter.maxCountEntriesIfSettingItIsAllowed, if (parameter.isSettingMaxCountEntriesAllowedByBank) Existenzstatus.Optional else Existenzstatus.NotAllowed) // > 0. O: „Eingabe Anzahl Einträge erlaubt“ (BPD) = „J“. N: sonst
}

View File

@ -21,6 +21,8 @@ enum class CustomerSegmentId(override val id: String) : ISegmentId {
AccountTransactionsMt940("HKKAZ"), AccountTransactionsMt940("HKKAZ"),
CreditCardTransactions("DKKKU"),
SepaBankTransfer("HKCCS"), SepaBankTransfer("HKCCS"),
SepaInstantPaymentBankTransfer("HKIPZ"), SepaInstantPaymentBankTransfer("HKIPZ"),

View File

@ -34,6 +34,6 @@ abstract class KontoumsaetzeZeitraumMt940Base(
AlleKonten(false, Existenzstatus.Mandatory), // currently no supported, we retrieve account transactions account by account (most banks don't support AlleKonten anyway) AlleKonten(false, Existenzstatus.Mandatory), // currently no supported, we retrieve account transactions account by account (most banks don't support AlleKonten anyway)
Datum(parameter.fromDate, Existenzstatus.Optional), Datum(parameter.fromDate, Existenzstatus.Optional),
Datum(parameter.toDate, Existenzstatus.Optional), Datum(parameter.toDate, Existenzstatus.Optional),
MaximaleAnzahlEintraege(parameter.maxCountEntriesIfSettingItIsAllowed, if (parameter.isSettingMaxCountEntriesAllowedByBank) Existenzstatus.Optional else Existenzstatus.NotAllowed), // > 0. O: „Eingabe Anzahl Einträge erlaubt“ (BPD) = „J“. N: sonst MaximaleAnzahlEintraege(parameter),
Aufsetzpunkt(null, Existenzstatus.Optional) // will be set dynamically, see MessageBuilder.rebuildMessageWithContinuationId(); M: vom Institut wurde ein Aufsetzpunkt rückgemeldet. N: sonst Aufsetzpunkt(null, Existenzstatus.Optional) // will be set dynamically, see MessageBuilder.rebuildMessageWithContinuationId(); M: vom Institut wurde ein Aufsetzpunkt rückgemeldet. N: sonst
)) ))

View File

@ -0,0 +1,27 @@
package net.dankito.banking.fints.messages.segmente.implementierte.umsaetze
import net.dankito.banking.fints.messages.Existenzstatus
import net.dankito.banking.fints.messages.datenelemente.abgeleiteteformate.Datum
import net.dankito.banking.fints.messages.datenelemente.basisformate.AlphanumerischesDatenelement
import net.dankito.banking.fints.messages.datenelemente.implementierte.Aufsetzpunkt
import net.dankito.banking.fints.messages.datenelemente.implementierte.account.MaximaleAnzahlEintraege
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.Segmentkopf
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.account.Kontoverbindung
import net.dankito.banking.fints.messages.segmente.Segment
import net.dankito.banking.fints.messages.segmente.id.CustomerSegmentId
import net.dankito.banking.fints.model.GetTransactionsParameter
open class KreditkartenUmsaetze(
segmentNumber: Int,
parameter: GetTransactionsParameter
) : Segment(listOf(
Segmentkopf(CustomerSegmentId.CreditCardTransactions, 2, segmentNumber),
Kontoverbindung(parameter.account),
AlphanumerischesDatenelement(parameter.account.accountIdentifier, Existenzstatus.Mandatory),
AlphanumerischesDatenelement(parameter.account.accountIdentifier, Existenzstatus.Mandatory), // TODO: find out what this value really should be; works for Comdirect, but does it work generally?
Datum(parameter.fromDate, Existenzstatus.Optional),
Datum(parameter.toDate, Existenzstatus.Optional),
MaximaleAnzahlEintraege(parameter),
Aufsetzpunkt(null, Existenzstatus.Optional) // will be set dynamically, see MessageBuilder.rebuildMessageWithContinuationId(); M: vom Institut wurde ein Aufsetzpunkt rückgemeldet. N: sonst
))

View File

@ -44,7 +44,11 @@ open class AccountTransaction(
) { ) {
// for object deserializers // for object deserializers
internal constructor() : this(AccountData(), Money(Amount.Zero, ""), false, "", Date(0), null, null, null, null, Date(0), 0, null, null, null, internal constructor() : this(AccountData(), Money(Amount.Zero, ""), "", Date(0), null, null, null, null, Date(0))
constructor(account: AccountData, amount: Money, unparsedUsage: String, bookingDate: Date, otherPartyName: String?, otherPartyBankCode: String?, otherPartyAccountId: String?, bookingText: String?, valueDate: Date)
: this(account, amount, false, unparsedUsage, bookingDate, otherPartyName, otherPartyBankCode, otherPartyAccountId, bookingText, valueDate,
0, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, "", "", null, null, "", null) null, "", "", null, null, "", null)

View File

@ -0,0 +1,11 @@
package net.dankito.banking.fints.model
import net.dankito.utils.multiplatform.Date
open class CreditCardTransaction(
val amount: Money,
val otherPartyName: String,
val bookingDate: Date,
val valueDate: Date
)

View File

@ -39,6 +39,8 @@ enum class InstituteSegmentId(override val id: String) : ISegmentId {
AccountTransactionsMt940("HIKAZ"), AccountTransactionsMt940("HIKAZ"),
AccountTransactionsMt940Parameters(AccountTransactionsMt940.id + "S") AccountTransactionsMt940Parameters(AccountTransactionsMt940.id + "S"),
CreditCardTransactions("DIKKU")
} }

View File

@ -15,6 +15,8 @@ import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.acc
import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.signatur.Sicherheitsprofil import net.dankito.banking.fints.messages.datenelementgruppen.implementierte.signatur.Sicherheitsprofil
import net.dankito.banking.fints.messages.segmente.id.MessageSegmentId import net.dankito.banking.fints.messages.segmente.id.MessageSegmentId
import net.dankito.banking.fints.model.Amount import net.dankito.banking.fints.model.Amount
import net.dankito.banking.fints.model.CreditCardTransaction
import net.dankito.banking.fints.model.Money
import net.dankito.banking.fints.response.segments.* import net.dankito.banking.fints.response.segments.*
import net.dankito.banking.fints.util.MessageUtils import net.dankito.banking.fints.util.MessageUtils
import net.dankito.utils.multiplatform.Date import net.dankito.utils.multiplatform.Date
@ -27,7 +29,7 @@ open class ResponseParser(
) { ) {
companion object { companion object {
val JobParametersSegmentRegex = Regex("HI[A-Z]{3}S") val JobParametersSegmentRegex = Regex("[H|D]I[A-Z]{3}S")
const val FeedbackParametersSeparator = "; " const val FeedbackParametersSeparator = "; "
@ -116,6 +118,8 @@ open class ResponseParser(
InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups) InstituteSegmentId.AccountTransactionsMt940.id -> parseMt940AccountTransactions(segment, dataElementGroups)
InstituteSegmentId.AccountTransactionsMt940Parameters.id -> parseMt940AccountTransactionsParameters(segment, segmentId, dataElementGroups) InstituteSegmentId.AccountTransactionsMt940Parameters.id -> parseMt940AccountTransactionsParameters(segment, segmentId, dataElementGroups)
InstituteSegmentId.CreditCardTransactions.id -> parseCreditCardTransactions(segment, dataElementGroups)
else -> { else -> {
if (JobParametersSegmentRegex.matches(segmentId)) { if (JobParametersSegmentRegex.matches(segmentId)) {
return parseJobParameters(segment, segmentId, dataElementGroups) return parseJobParameters(segment, segmentId, dataElementGroups)
@ -267,10 +271,8 @@ open class ResponseParser(
if (dataElements.size > 0) { if (dataElements.size > 0) {
val jobName = parseString(dataElements[0]) val jobName = parseString(dataElements[0])
if (jobName.startsWith("HK")) { // filter out jobs not standardized by Deutsche Kreditwirtschaft (Verbandseigene Geschaeftsvorfaelle)
return jobName return jobName
} }
}
return null return null
} }
@ -312,7 +314,7 @@ open class ResponseParser(
protected open fun parseJobParameters(segment: String, segmentId: String, dataElementGroups: List<String>): JobParameters { protected open fun parseJobParameters(segment: String, segmentId: String, dataElementGroups: List<String>): JobParameters {
var jobName = segmentId.substring(0, 5) // cut off last 'S' (which stands for 'parameter') var jobName = segmentId.substring(0, 5) // cut off last 'S' (which stands for 'parameter')
jobName = jobName.replaceFirst("HI", "HK") jobName = jobName.replaceFirst("HI", "HK").replaceFirst("DI", "DK")
val maxCountJobs = parseInt(dataElementGroups[1]) val maxCountJobs = parseInt(dataElementGroups[1])
val minimumCountSignatures = parseInt(dataElementGroups[2]) val minimumCountSignatures = parseInt(dataElementGroups[2])
@ -635,21 +637,28 @@ open class ResponseParser(
protected open fun parseBalance(dataElementGroup: String): Balance { protected open fun parseBalance(dataElementGroup: String): Balance {
val dataElements = getDataElements(dataElementGroup) val dataElements = getDataElements(dataElementGroup)
val isCredit = parseString(dataElements[0]) == "C" val isCredit = parseIsCredit(dataElements[0])
var currency: String? = null
var dateIndex = 2 var dateIndex = 2
var date: Date? = parseNullableDate(dataElements[dateIndex]) // in older versions dateElements[2] was the currency var date: Date? = parseNullableDate(dataElements[dateIndex]) // in older versions dateElements[2] was the currency
if (date == null) { if (date == null) {
currency = parseString(dataElements[dateIndex])
date = parseDate(dataElements[++dateIndex]) date = parseDate(dataElements[++dateIndex])
} }
return Balance( return Balance(
parseAmount(dataElements[1], isCredit), parseAmount(dataElements[1], isCredit),
currency,
date, date,
if (dataElements.size > dateIndex + 1) parseTime(dataElements[dateIndex + 1]) else null if (dataElements.size > dateIndex + 1) parseTime(dataElements[dateIndex + 1]) else null
) )
} }
protected open fun parseIsCredit(isCredit: String): Boolean {
return parseString(isCredit) == "C"
}
protected open fun parseMt940AccountTransactions(segment: String, dataElementGroups: List<String>): ReceivedAccountTransactions { protected open fun parseMt940AccountTransactions(segment: String, dataElementGroups: List<String>): ReceivedAccountTransactions {
val bookedTransactionsString = extractBinaryData(dataElementGroups[1]) val bookedTransactionsString = extractBinaryData(dataElementGroups[1])
@ -673,6 +682,42 @@ open class ResponseParser(
} }
protected open fun parseCreditCardTransactions(segment: String, dataElementGroups: List<String>): ReceivedCreditCardTransactionsAndBalance {
val balance = parseBalance(dataElementGroups[3])
val transactionsDataElementGroups = dataElementGroups.subList(6, dataElementGroups.size)
return ReceivedCreditCardTransactionsAndBalance(
balance,
transactionsDataElementGroups.map { mapCreditCardTransaction(it) },
segment
)
}
protected open fun mapCreditCardTransaction(transactionDataElementGroup: String): CreditCardTransaction {
val dataElements = getDataElements(transactionDataElementGroup)
val bookingDate = parseDate(dataElements[1])
val valueDate = parseDate(dataElements[2])
val amount = parseCreditCardAmount(dataElements.subList(4, 7))
val otherPartyName = parseString(dataElements[11])
return CreditCardTransaction(amount, otherPartyName, bookingDate, valueDate)
}
private fun parseCreditCardAmount(amountDataElements: List<String>): Money {
val currency = parseString(amountDataElements[1])
val isCredit = parseIsCredit(amountDataElements[2])
var amountString = parseString(amountDataElements[0])
if (isCredit == false) {
amountString = "-" + amountString
}
return Money(Amount(amountString), currency)
}
protected open fun parseBankDetails(dataElementsGroup: String): Kreditinstitutskennung { protected open fun parseBankDetails(dataElementsGroup: String): Kreditinstitutskennung {
val detailsStrings = getDataElements(dataElementsGroup) val detailsStrings = getDataElements(dataElementsGroup)

View File

@ -6,6 +6,7 @@ import net.dankito.utils.multiplatform.Date
open class Balance( open class Balance(
val amount: Amount, val amount: Amount,
val currency: String?,
val date: Date, val date: Date,
val time: Date? val time: Date?
) { ) {

View File

@ -6,5 +6,4 @@ open class ReceivedAccountTransactions(
val unbookedTransactionsString: String?, // TODO val unbookedTransactionsString: String?, // TODO
segmentString: String segmentString: String
) ) : ReceivedSegment(segmentString)
: ReceivedSegment(segmentString)

View File

@ -0,0 +1,11 @@
package net.dankito.banking.fints.response.segments
import net.dankito.banking.fints.model.CreditCardTransaction
open class ReceivedCreditCardTransactionsAndBalance(
val balance: Balance,
val transactions: List<CreditCardTransaction>,
segmentString: String
) : ReceivedSegment(segmentString)

View File

@ -1084,6 +1084,41 @@ class ResponseParserTest : FinTsTestBase() {
} }
@Test
fun parseCreditCardAccountTransactions() {
// given
val creditCardNumber = "4263540122107989"
val balance = "189,5"
val otherPartyName = "Bundesanzeiger Verlag Koeln 000"
val amount = "6,5"
// when
val result = underTest.parse("DIKKU:7:2:3+$creditCardNumber++C:$balance:EUR:20200923:021612+++" +
"$creditCardNumber:20200819:20200820::$amount:EUR:D:1,:$amount:EUR:D:$otherPartyName:::::::::J:120082048947201+" +
"$creditCardNumber:20200819:20200820::$amount:EUR:D:1,:$amount:EUR:D:$otherPartyName:::::::::J:120082048947101'")
// then
assertSuccessfullyParsedSegment(result, InstituteSegmentId.CreditCardTransactions, 7, 2, 3)
result.getFirstSegmentById<ReceivedCreditCardTransactionsAndBalance>(InstituteSegmentId.CreditCardTransactions)?.let { segment ->
expect(segment.balance.amount.string).toBe(balance)
expect(segment.balance.date).toBe(Date(2020, 9, 23))
expect(segment.balance.time).notToBeNull()
expect(segment.transactions.size).toBe(2)
segment.transactions.forEach { transaction ->
expect(transaction.otherPartyName).toBe(otherPartyName)
expect(transaction.bookingDate).toBe(Date(2020, 8, 19))
expect(transaction.valueDate).toBe(Date(2020, 8, 20))
expect(transaction.amount.amount.string).toBe("-" + amount)
expect(transaction.amount.currency.code).toBe("EUR")
}
}
?: run { fail("No segment of type ReceivedCreditCardTransactionsAndBalance found in ${result.receivedSegments}") }
}
private fun assertSuccessfullyParsedSegment(result: BankResponse, segmentId: ISegmentId, segmentNumber: Int, private fun assertSuccessfullyParsedSegment(result: BankResponse, segmentId: ISegmentId, segmentNumber: Int,
segmentVersion: Int, referenceSegmentNumber: Int? = null) { segmentVersion: Int, referenceSegmentNumber: Int? = null) {