Compare commits

...

17 Commits

Author SHA1 Message Date
dankito bd052587c5 Added example how to update account transactions 2024-09-03 22:53:26 +02:00
dankito 36391c8f20 Also updating retrievedTransactionsFrom on BankAccount 2024-09-03 22:31:05 +02:00
dankito 7f60e02340 Implemented BankingModelService to filter out duplicates in retrieved AccountTransactions 2024-09-03 22:30:52 +02:00
dankito 85b66aa040 Also implemented an identifier for AccountTransaction 2024-09-03 22:23:52 +02:00
dankito a9ebad0793 Fixed identifying TAN media as e.g. Sparkasse named all their TanGenerator media "SparkassenCard (Debitkarte)" - so it was not possible to differentiate between them 2024-09-03 22:23:27 +02:00
dankito 5bfe492ada Moved balance up 2024-09-03 22:18:32 +02:00
dankito f17c9bb781 Could remove unused date time extensions 2024-09-03 21:35:50 +02:00
dankito 469ee275c9 Updated to fints4k changes: lastTransactionRetrievalTime has been renamed to lastTransactionsRetrievalTime and its type has been changed to Instant 2024-09-03 21:35:26 +02:00
dankito ba662fda15 Fixed using fints4k-banking-client directly 2024-09-03 20:39:01 +02:00
dankito 80656bdcdd Fixed name 2024-09-03 20:37:54 +02:00
dankito 000a169a00 Added updateAccountTransactions() to BankingClient 2024-09-03 02:17:22 +02:00
dankito 9a2bc6b430 Also added displayName for UserAccount and both respect now userSetDisplayName 2024-09-03 02:13:02 +02:00
dankito c3c0b2830e Added displayName to be able to replace shortcut `productName ?: identifier` in application 2024-09-03 01:30:01 +02:00
dankito aab562ccf4 Updated to that fints4k replaced retrievedTransactionsTo with lastTransactionRetrievalTime 2024-09-03 01:08:04 +02:00
dankito b210c1e7fa Made properties open 2024-09-02 19:40:18 +02:00
dankito b0b0fa6140 Fixed mapping unparsedReference / sepaReference to reference 2024-09-02 19:39:45 +02:00
dankito f1dad3bc26 Mapped fints4k Decoupled TAN model 2024-09-02 19:38:37 +02:00
22 changed files with 377 additions and 54 deletions

View File

@ -1,8 +1,10 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.GetTransactionsResponse
import net.codinux.banking.client.model.response.Response
interface BankingClient {
@ -30,4 +32,11 @@ interface BankingClient {
*/
suspend fun getAccountDataAsync(request: GetAccountDataRequest): Response<GetAccountDataResponse>
/**
* Convenience wrapper around [getAccountDataAsync].
* Updates account's transactions beginning from [BankAccount.lastTransactionsRetrievalTime].
* This may requires TAN if [BankAccount.lastTransactionsRetrievalTime] is older than 90 days.
*/
suspend fun updateAccountTransactionsAsync(user: UserAccount, accounts: List<BankAccount>? = null): Response<List<GetTransactionsResponse>>
}

View File

@ -1,8 +1,10 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.GetTransactionsResponse
import net.codinux.banking.client.model.response.Response
interface BankingClientForUser {
@ -30,4 +32,11 @@ interface BankingClientForUser {
*/
suspend fun getAccountDataAsync(options: GetAccountDataOptions): Response<GetAccountDataResponse>
/**
* Convenience wrapper around [getAccountDataAsync].
* Updates account's transactions beginning from [BankAccount.lastTransactionsRetrievalTime].
* This may requires TAN if [BankAccount.lastTransactionsRetrievalTime] is older than 90 days.
*/
suspend fun updateAccountTransactionsAsync(): Response<List<GetTransactionsResponse>>
}

View File

@ -1,15 +1,27 @@
package net.codinux.banking.client
import net.codinux.banking.client.model.AccountCredentials
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetTransactionsResponse
import net.codinux.banking.client.model.response.Response
abstract class BankingClientForUserBase(
protected val credentials: AccountCredentials,
protected val client: BankingClient
) : BankingClientForUser {
private lateinit var user: UserAccount
override suspend fun getAccountDataAsync(options: GetAccountDataOptions) =
client.getAccountDataAsync(GetAccountDataRequest(credentials, options))
client.getAccountDataAsync(GetAccountDataRequest(credentials, options)).also {
it.data?.user?.let { retrievedUser ->
this.user = retrievedUser
}
}
override suspend fun updateAccountTransactionsAsync(): Response<List<GetTransactionsResponse>> =
client.updateAccountTransactionsAsync(user)
}

View File

@ -0,0 +1,24 @@
package net.codinux.banking.client.service
import net.codinux.banking.client.model.AccountTransaction
open class BankingModelService {
/**
* It's not possible to retrieve only new transactions from bank server (almost no bank implements HKKAN job). So
* for updating account transactions we start at the date of latest account transactions retrieval time (e.g.
* transactions have at last been fetched at 01. September 12:00, then there may have been some other transactions
* been booked on September 1st after 12:00 o'clock).
*
* Therefore retrieved account transactions may contain transactions that we already have locally. This method filters
* from [retrievedTransactions] those already in [existingTransactions] and returns only that ones, that are not in
* [existingTransactions].
*/
open fun findNewTransactions(retrievedTransactions: List<AccountTransaction>, existingTransactions: List<AccountTransaction>): List<AccountTransaction> {
val existingTransactionsByIdentifier = existingTransactions.associateBy { it.identifier }
val existingTransactionsIdentifiers = existingTransactionsByIdentifier.keys
return retrievedTransactions.filter { transaction -> existingTransactionsIdentifiers.contains(transaction.identifier) == false }
}
}

View File

@ -1,6 +1,8 @@
package net.codinux.banking.client
import kotlinx.coroutines.runBlocking
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
@ -12,6 +14,11 @@ fun BankingClient.getAccountData(request: GetAccountDataRequest) = runBlocking {
this@getAccountData.getAccountDataAsync(request)
}
fun BankingClient.updateAccountTransactions(user: UserAccount, accounts: List<BankAccount>? = null) = runBlocking {
this@updateAccountTransactions.updateAccountTransactionsAsync(user, accounts)
}
fun BankingClientForUser.getAccountData() = runBlocking {
this@getAccountData.getAccountDataAsync()
}
@ -19,3 +26,7 @@ fun BankingClientForUser.getAccountData() = runBlocking {
fun BankingClientForUser.getAccountData(options: GetAccountDataOptions) = runBlocking {
this@getAccountData.getAccountDataAsync(options)
}
fun BankingClientForUser.updateAccountTransactions() = runBlocking {
this@updateAccountTransactions.updateAccountTransactionsAsync()
}

View File

@ -7,7 +7,7 @@ import net.codinux.banking.client.model.config.NoArgConstructor
open class AccountTransaction(
val amount: Amount = Amount.Zero,
val currency: String,
val reference: String, // Alternative: purpose (or Remittance information)
val unparsedReference: String, // Alternative: purpose (or Remittance information)
/**
* Transaction date (Buchungstag) - der Tag, an dem ein Zahlungsvorgang in das System einer Bank eingegangen ist.
@ -62,5 +62,13 @@ open class AccountTransaction(
var category: String? = null,
var notes: String? = null,
) {
val reference: String
get() = sepaReference ?: unparsedReference
open val identifier by lazy {
"$amount $currency $bookingDate $valueDate $unparsedReference $sepaReference $otherPartyName $otherPartyBankCode $otherPartyAccountId"
}
override fun toString() = "${valueDate.dayOfMonth}.${valueDate.monthNumber}.${valueDate.year} ${amount.toString().padStart(4, ' ')} ${if (currency == "EUR") "€" else currency} ${otherPartyName ?: ""} - $reference"
}

View File

@ -1,6 +1,8 @@
package net.codinux.banking.client.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
@ -14,13 +16,14 @@ open class BankAccount(
val currency: String = "EUR",
var accountLimit: String? = null,
val isAccountTypeSupportedByApplication: Boolean = true,
val features: Set<BankAccountFeatures> = emptySet(),
// var balance: BigDecimal = BigDecimal.ZERO,
var balance: Amount = Amount.Zero, // TODO: add a BigDecimal library
val isAccountTypeSupportedByApplication: Boolean = false,
val features: Set<BankAccountFeatures> = emptySet(),
open var lastTransactionsRetrievalTime: Instant? = null,
var retrievedTransactionsFrom: LocalDate? = null,
var retrievedTransactionsTo: LocalDate? = null,
var haveAllTransactionsBeenRetrieved: Boolean = false,
val countDaysForWhichTransactionsAreKept: Int? = null,
@ -34,5 +37,12 @@ open class BankAccount(
var hideAccount: Boolean = false,
var includeInAutomaticAccountsUpdate: Boolean = true
) {
@get:JsonIgnore
open val displayName: String
get() = userSetDisplayName ?: productName ?: identifier
fun supportsAnyFeature(vararg features: BankAccountFeatures): Boolean =
features.any { this.features.contains(it) }
override fun toString() = "$type $identifier $productName (IBAN: $iban)"
}

View File

@ -51,6 +51,11 @@ open class UserAccount(
var displayIndex: Int = 0
@get:JsonIgnore
open val displayName: String
get() = userSetDisplayName ?: bankName
@get:JsonIgnore
val selectedTanMethod: TanMethod
get() = tanMethods.first { it.identifier == selectedTanMethodId }

View File

@ -0,0 +1,22 @@
package net.codinux.banking.client.model.response
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.UnbookedAccountTransaction
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class GetTransactionsResponse(
val account: BankAccount,
val balance: Amount? = null,
val bookedTransactions: List<AccountTransaction>,
val unbookedTransactions: List<UnbookedAccountTransaction>,
val transactionsRetrievalTime: Instant,
val retrievedTransactionsFrom: LocalDate? = null,
val retrievedTransactionsTo: LocalDate? = null
) {
override fun toString() = "${account.productName} $balance, ${bookedTransactions.size} booked transactions from $retrievedTransactionsFrom"
}

View File

@ -9,9 +9,9 @@ import net.codinux.banking.client.model.config.NoArgConstructor
@Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor
open class TanChallenge(
val type: TanChallengeType,
val forAction: ActionRequiringTan,
val messageToShowToUser: String,
open val type: TanChallengeType,
open val forAction: ActionRequiringTan,
open val messageToShowToUser: String,
/**
* Identifier of selected TanMethod.
@ -19,7 +19,7 @@ open class TanChallenge(
* As [availableTanMethods] also contains selected TanMethod, we didn't want to duplicate this object. Use
* [selectedTanMethod] to get selected TanMethod or iterate over [availableTanMethods] and filter selected one by this id.
*/
val selectedTanMethodId: String,
open val selectedTanMethodId: String,
/**
* When adding an account, frontend has no UserAccount object in BankingClientCallback to know which TanMethods are
* available for User.
@ -28,7 +28,7 @@ open class TanChallenge(
*
* Therefore i added list with up to date TanMethods here to ensure EnterTanDialog can display user's up to date TanMethods.
*/
val availableTanMethods: List<TanMethod>,
open val availableTanMethods: List<TanMethod>,
/**
* Identifier of selected TanMedium.
@ -36,24 +36,35 @@ open class TanChallenge(
* As [availableTanMedia] also contains selected TanMedium, we didn't want to duplicate this object. Use
* [selectedTanMedium] to get selected TanMedium or iterate over [availableTanMedia] and filter selected one by this medium name.
*/
val selectedTanMediumName: String? = null,
val availableTanMedia: List<TanMedium> = emptyList(),
open val selectedTanMediumName: String? = null,
open val availableTanMedia: List<TanMedium> = emptyList(),
val tanImage: TanImage? = null,
val flickerCode: FlickerCode? = null,
val user: UserAccountViewInfo,
val account: BankAccountViewInfo? = null
open val tanImage: TanImage? = null,
open val flickerCode: FlickerCode? = null,
open val user: UserAccountViewInfo,
open val account: BankAccountViewInfo? = null
) {
@get:JsonIgnore
val selectedTanMethod: TanMethod
open val selectedTanMethod: TanMethod
get() = availableTanMethods.first { it.identifier == selectedTanMethodId }
@get:JsonIgnore
val selectedTanMedium: TanMedium?
open val selectedTanMedium: TanMedium?
get() = availableTanMedia.firstOrNull { it.mediumName == selectedTanMediumName }
/**
* Principally a no-op method, not implemented for all client, only implementing client for not: FinTs4jBankingClient.
*
* If a TAN is requested for a decoupled TAN method like [TanMethodType.DecoupledTan] or [TanMethodType.DecoupledPushTan],
* you can add a callback to get notified when user approved TAN in her app e.g. to close a EnterTanDialog.
*/
open fun addUserApprovedDecoupledTanCallback(callback: () -> Unit) {
}
override fun toString(): String {
return "$selectedTanMethod $forAction: $messageToShowToUser" + when (type) {
TanChallengeType.EnterTan -> ""

View File

@ -4,6 +4,10 @@ import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
/**
* 'TanGenerator' is in most cases a debit card, but can also be something like "BestSign" app (Postbank).
* In latter case [cardNumber] can also be, contrary to specification, be an empty string.
*/
open class TanGeneratorTanMedium(
val cardNumber: String,
val cardSequenceNumber: String? = null,

View File

@ -16,5 +16,49 @@ open class TanMedium(
*/
val mobilePhone: MobilePhoneTanMedium? = null
) {
/**
* Using only [mediumName] as identifier does not work as e.g. Sparkasse names all their TanGenerator TAN media
* "SparkassenCard (Debitkarte)" - so it's not possible to differentiate between them solely by medium name.
*/
val identifier: String by lazy {
// TODO: translate
var id = mediumName ?: when (type) {
TanMediumType.MobilePhone -> "Mobiltelefon"
TanMediumType.TanGenerator -> "Tan Generator"
TanMediumType.Generic -> "Unbenanntes TAN Medium"
}
if (mobilePhone != null) {
id += " " + (mobilePhone.concealedPhoneNumber ?: mobilePhone.phoneNumber)
}
if (tanGenerator != null) {
if (tanGenerator.cardNumber.isNotBlank()) {
id += " " + tanGenerator.cardNumber
}
if (tanGenerator.cardSequenceNumber.isNullOrBlank() == false) {
id += " " + tanGenerator.cardSequenceNumber
}
if (tanGenerator.validFrom != null && tanGenerator.validTo != null) {
id += ", gültig von " + tanGenerator.validFrom.let { "${it.dayOfMonth}.${it.monthNumber}${it.year}" } +
" - " + tanGenerator.validTo.let { "${it.dayOfMonth}.${it.monthNumber}${it.year}" }
} else if (tanGenerator.validTo != null) {
id += ", gültig bis " + tanGenerator.validTo.let { "${it.dayOfMonth}.${it.monthNumber}${it.year}" }
}
}
id
}
val displayName: String by lazy {
identifier + " " + when (status) {
TanMediumStatus.Used -> "Aktive"
TanMediumStatus.Available -> "Verfügbar"
TanMediumStatus.ActiveFollowUpCard -> " Folgekarte, aktiv bei erster Nutzung"
TanMediumStatus.AvailableFollowUpCard -> " Folgekarte, die erst aktiviert werden muss"
}
}
override fun toString() = "$mediumName $status"
}

View File

@ -4,11 +4,11 @@ import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class TanMethod(
val displayName: String,
val type: TanMethodType,
val identifier: String,
val maxTanInputLength: Int? = null,
val allowedTanFormat: AllowedTanFormat = AllowedTanFormat.Alphanumeric
open val displayName: String,
open val type: TanMethodType,
open val identifier: String,
open val maxTanInputLength: Int? = null,
open val allowedTanFormat: AllowedTanFormat = AllowedTanFormat.Alphanumeric
) {
override fun toString() = "$displayName ($type, ${identifier})"
}

View File

@ -17,22 +17,30 @@ enum class TanMethodType {
AppTan,
DecoupledTan,
DecoupledPushTan,
photoTan,
QrCode
;
val isDecoupledMethod: Boolean
get() = this == DecoupledTan || this == DecoupledPushTan
companion object {
val NonVisual = listOf(TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
val NonVisual = listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode)
val NonVisualOrImageBased = buildList {
addAll(listOf(TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.EnterTan))
addAll(listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.EnterTan))
addAll(ImageBased)
addAll(listOf(TanMethodType.ChipTanManuell)) // this is quite inconvenient for user, so i added it at last
addAll(listOf(TanMethodType.ChipTanManuell)) // this is quite inconvenient for user, so i added it as last
}
}

View File

@ -77,7 +77,7 @@ kotlin {
dependencies {
api(project(":BankingClient"))
api("net.codinux.banking:fints4k:1.0.0-Alpha-12")
api("net.codinux.banking:fints4k:1.0.0-Alpha-13-SNAPSHOT")
api("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinxDateTimeVersion")
}

View File

@ -2,12 +2,15 @@ package net.codinux.banking.client.fints4k
import net.codinux.banking.client.BankingClient
import net.codinux.banking.client.BankingClientCallback
import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.BankAccountFeatures
import net.codinux.banking.client.model.UserAccount
import net.codinux.banking.client.model.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
import net.codinux.banking.client.model.response.*
import net.codinux.banking.fints.FinTsClient
import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.model.BankData
open class FinTs4kBankingClient(
config: FinTsClientConfiguration = FinTsClientConfiguration(),
@ -28,4 +31,28 @@ open class FinTs4kBankingClient(
return mapper.map(response)
}
override suspend fun updateAccountTransactionsAsync(user: UserAccount, accounts: List<BankAccount>?): Response<List<GetTransactionsResponse>> {
val accountsToRequest = (accounts ?: user.accounts).filter { it.supportsAnyFeature(BankAccountFeatures.RetrieveBalance, BankAccountFeatures.RetrieveBalance) }
if (accountsToRequest.isNotEmpty()) {
var finTsModel: BankData? = null
val responses = accountsToRequest.map { account ->
val parameter = mapper.mapToUpdateAccountTransactionsParameter(user, account, finTsModel)
val response = client.getAccountDataAsync(parameter)
if (response.finTsModel != null) {
finTsModel = response.finTsModel // so that basic account data doesn't have to be retrieved another time if user has multiple accounts
}
Triple(account, parameter, response)
}
return mapper.map(responses)
}
return Response.error(ErrorType.NoneOfTheAccountsSupportsRetrievingData, "Keiner der Konten unterstützt das Abholen der Umsätze oder des Kontostands") // TODO: translate
}
}

View File

@ -5,7 +5,7 @@ import net.codinux.banking.client.BankingClientForUserBase
import net.codinux.banking.client.model.AccountCredentials
import net.codinux.banking.fints.config.FinTsClientConfiguration
open class FinTs4KBankingClientForUser(credentials: AccountCredentials, config: FinTsClientConfiguration = FinTsClientConfiguration(), callback: BankingClientCallback)
open class FinTs4kBankingClientForUser(credentials: AccountCredentials, config: FinTsClientConfiguration = FinTsClientConfiguration(), callback: BankingClientCallback)
: BankingClientForUserBase(credentials, FinTs4kBankingClient(config, callback)) {
constructor(bankCode: String, loginName: String, password: String, callback: BankingClientCallback)

View File

@ -1,5 +1,8 @@
package net.codinux.banking.client.fints4k
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.client.model.*
import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount
@ -11,6 +14,7 @@ import net.codinux.banking.client.model.tan.TanChallenge
import net.codinux.banking.client.model.tan.TanImage
import net.codinux.banking.client.model.tan.TanMethod
import net.codinux.banking.client.model.tan.TanMethodType
import net.codinux.banking.fints.extensions.EuropeBerlin
import net.dankito.banking.client.model.BankAccountIdentifierImpl
import net.dankito.banking.client.model.parameter.GetAccountDataParameter
import net.dankito.banking.client.model.parameter.RetrieveTransactions
@ -41,6 +45,21 @@ open class FinTs4kMapper {
abortIfTanIsRequired = options.abortIfTanIsRequired
)
open fun mapToUpdateAccountTransactionsParameter(user: UserAccount, account: BankAccount, finTsModel: BankData?): GetAccountDataParameter {
val accountIdentifier = BankAccountIdentifierImpl(account.identifier, account.subAccountNumber, account.iban)
val from = account.lastTransactionsRetrievalTime?.toLocalDateTime(TimeZone.EuropeBerlin)?.date // TODO: in case lastTransactionsUpdateTime is not set, this would retrieve all transactions (and require a TAN im most cases)
val retrieveTransactions = if (from != null) RetrieveTransactions.AccordingToRetrieveFromAndTo else RetrieveTransactions.OfLast90Days
// val preferredTanMethods = listOf(mapTanMethodType(user.selectedTanMethod.type)) // TODO: currently we aren't saving TanMethods in database, re-enable as soon as TanMethods get saved
val preferredTanMethods = emptyList<net.codinux.banking.fints.model.TanMethodType>()
return GetAccountDataParameter(user.bankCode, user.loginName, user.password!!, listOf(accountIdentifier), true,
retrieveTransactions, from,
preferredTanMethods = preferredTanMethods,
preferredTanMedium = user.selectedTanMediumName,
finTsModel = finTsModel
)
}
protected open fun mapTanMethodType(type: TanMethodType): net.codinux.banking.fints.model.TanMethodType =
net.codinux.banking.fints.model.TanMethodType.valueOf(type.name)
@ -53,6 +72,35 @@ open class FinTs4kMapper {
}
}
open fun map(responses: List<Triple<BankAccount, GetAccountDataParameter, net.dankito.banking.client.model.response.GetAccountDataResponse>>): Response<List<GetTransactionsResponse>> {
val type = if (responses.all { it.third.successful }) ResponseType.Success else ResponseType.Error
// TODO: update UserAccount and BankAccount objects according to retrieved data
val mappedResponses = responses.map { (account, param, getAccountDataResponse) ->
val user = getAccountDataResponse.customerAccount
val finTsBankAccount = user?.accounts?.firstOrNull { it.identifier == account.identifier && it.subAccountNumber == account.subAccountNumber }
if (getAccountDataResponse.successful && user != null && finTsBankAccount != null) {
if (finTsBankAccount.lastTransactionsRetrievalTime != null) {
account.lastTransactionsRetrievalTime = finTsBankAccount.lastTransactionsRetrievalTime
}
if (account.retrievedTransactionsFrom == null || (finTsBankAccount.retrievedTransactionsFrom != null
&& account.retrievedTransactionsFrom!! < finTsBankAccount.retrievedTransactionsFrom!!)) {
account.retrievedTransactionsFrom = finTsBankAccount.retrievedTransactionsFrom
}
Response.success(GetTransactionsResponse(account, mapAmount(finTsBankAccount.balance), mapBookedTransactions(finTsBankAccount), emptyList(),
finTsBankAccount.lastTransactionsRetrievalTime ?: Clock.System.now(), param.retrieveTransactionsFrom, param.retrieveTransactionsTo))
} else {
mapError(getAccountDataResponse)
}
}
val data = mappedResponses.filter { it.type == ResponseType.Success }.mapNotNull { it.data }
return (object : Response<List<GetTransactionsResponse>>(type, data, mappedResponses.firstNotNullOfOrNull { it.error }) { })
}
open fun mapToUserAccountViewInfo(bank: BankData): UserAccountViewInfo = UserAccountViewInfo(
bank.bankCode, bank.customerId, bank.bankName, getBankingGroup(bank.bankName, bank.bic)
@ -82,12 +130,12 @@ open class FinTs4kMapper {
protected open fun mapAccount(account: net.dankito.banking.client.model.BankAccount) = BankAccount(
account.identifier, account.accountHolderName, mapAccountType(account.type), account.iban, account.subAccountNumber,
account.productName, account.currency, account.accountLimit, account.isAccountTypeSupportedByApplication,
mapFeatures(account),
mapAmount(account.balance), account.retrievedTransactionsFrom, account.retrievedTransactionsTo,
account.productName, account.currency, account.accountLimit, mapAmount(account.balance),
account.isAccountTypeSupportedByApplication, mapFeatures(account),
account.lastTransactionsRetrievalTime, account.retrievedTransactionsFrom,
// TODO: map haveAllTransactionsBeenRetrieved
countDaysForWhichTransactionsAreKept = account.countDaysForWhichTransactionsAreKept,
bookedTransactions = account.bookedTransactions.map { mapTransaction(it) }.toMutableList()
bookedTransactions = mapBookedTransactions(account).toMutableList()
)
protected open fun mapAccountType(type: net.dankito.banking.client.model.BankAccountType): BankAccountType =
@ -109,6 +157,9 @@ open class FinTs4kMapper {
}
protected open fun mapBookedTransactions(account: net.dankito.banking.client.model.BankAccount): List<AccountTransaction> =
account.bookedTransactions.map { mapTransaction(it) }
protected open fun mapTransaction(transaction: net.dankito.banking.client.model.AccountTransaction): AccountTransaction = AccountTransaction(
mapAmount(transaction.amount), transaction.amount.currency.code, transaction.unparsedReference,
transaction.bookingDate, transaction.valueDate,
@ -144,7 +195,8 @@ open class FinTs4kMapper {
val selectedTanMethodId = challenge.tanMethod.securityFunction.code
val tanMedia = challenge.bank.tanMedia.map { mapTanMedium(it) }
val selectedTanMediumName = challenge.bank.selectedTanMedium?.mediumName
// TanMedium has not natural id in FinTS model so we have to create our own one
val selectedTanMediumName = challenge.bank.selectedTanMedium?.let { selected -> tanMedia.firstOrNull { it == selected } }?.identifier
val user = mapToUserAccountViewInfo(challenge.bank)
val account = challenge.account?.let { mapToBankAccountViewInfo(it) }
@ -152,7 +204,11 @@ open class FinTs4kMapper {
val tanImage = if (challenge is ImageTanChallenge) mapTanImage(challenge.image) else null
val flickerCode = if (challenge is FlickerCodeTanChallenge) mapFlickerCode(challenge.flickerCode) else null
return TanChallenge(type, action, challenge.messageToShowToUser, selectedTanMethodId, tanMethods, selectedTanMediumName, tanMedia, tanImage, flickerCode, user, account)
return object : TanChallenge(type, action, challenge.messageToShowToUser, selectedTanMethodId, tanMethods, selectedTanMediumName, tanMedia, tanImage, flickerCode, user, account) {
override fun addUserApprovedDecoupledTanCallback(callback: () -> Unit) {
challenge.addUserApprovedDecoupledTanCallback(callback)
}
}
}
protected open fun mapTanChallengeType(challenge: net.codinux.banking.fints.model.TanChallenge): TanChallengeType = when {

View File

@ -19,7 +19,7 @@ class FinTs4kBankingClientTest {
}
private val underTest = FinTs4KBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
private val underTest = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
})

View File

@ -51,11 +51,11 @@ class ShowUsage {
fun getAccountData() {
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback())
val client = FinTs4kBankingClient(SimpleBankingClientCallback())
val response = client.getAccountData()
val response = client.getAccountData(bankCode, loginName, password) // that's all
printReceivedData(response)
printReceivedData(response) // now print retrieved data (save it to database, display it in UI, ...)
}
@ -82,15 +82,6 @@ class ShowUsage {
This fetches the booked account transactions of the last 90 days. In most cases no TAN is required for this.
In case there is, add TAN handling in Client Callback:
```kotlin
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
val tan: String? = null // if a TAN is required, add a UI or ...
callback.invoke(EnterTanResult(tan)) // ... set a break point here, get TAN e.g. from your TAN app, set tan variable in debugger view and resume debugger
})
```
You can also specify options e.g. which transactions should be retrieved:
```kotlin
@ -104,6 +95,15 @@ val options = GetAccountDataOptions(
val response = client.getAccountData(options)
```
Retrieving transactions older than 90 days requires a TAN, so add TAN handling in Client Callback:
```kotlin
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback { tanChallenge, callback ->
val tan: String? = null // if a TAN is required, add a UI or ...
callback.invoke(EnterTanResult(tan)) // ... set a break point here, get TAN e.g. from your TAN app, set tan variable in debugger view and resume debugger
})
```
Add some error handling by checking `response.error`:
```kotlin
@ -111,3 +111,37 @@ response.error?.let{ error ->
println("Could not fetch account data: ${error.internalError ?: error.errorMessagesFromBank.joinToString()}")
}
```
### Update Account Transactions
The data model saves when it retrieved account transactions the last time (in `BankAccount.lastTransactionsRetrievalTime`).
So you only need to call `FinTs4kBankingClient.updateAccountTransactions()` to retrieve all transactions starting from
`BankAccount.lastTransactionsRetrievalTime`.
But as we can only specify from which day on account transactions should be retrieved, response may contain some transactions
from the day of `lastTransactionsRetrievalTime` that we already have locally. To filter out these you can use
`BankingModelService().findNewTransactions(retrieveTransactions, existingTransactions)`:
```kotlin
fun updateAccountTransactions() {
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback())
// simulate account transactions we retrieved last time
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val lastCallToBankServer = client.getAccountData(GetAccountDataOptions(RetrieveTransactions.AccordingToRetrieveFromAndTo, retrieveTransactionsFrom = today.minusDays(90), retrieveTransactionsTo = today.minusDays(30)))
if (lastCallToBankServer.data != null) { // now update account transactions
val existingTransactions = lastCallToBankServer.data!!.bookedTransactions
val updateTransactionsResponse = client.updateAccountTransactions()
if (updateTransactionsResponse.data != null) {
val retrievedTransactions = updateTransactionsResponse.data!!.flatMap { it.bookedTransactions }
val newTransactions = BankingModelService().findNewTransactions(retrievedTransactions, existingTransactions)
// `retrievedTransactions` may contain transactions we already have locally, `newTransactions` only
// transactions that are not in `existingTransactions`
}
}
}
```

View File

@ -12,5 +12,5 @@ repositories {
dependencies {
implementation("net.codinux.banking.client:fints4k-banking-client:0.5.0")
implementation(project(":fints4k-banking-client"))
}

View File

@ -1,7 +1,11 @@
package net.codinux.banking.client.fints4k.example
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.client.SimpleBankingClientCallback
import net.codinux.banking.client.fints4k.FinTs4kBankingClient
import net.codinux.banking.client.fints4k.FinTs4kBankingClientForUser
import net.codinux.banking.client.getAccountData
import net.codinux.banking.client.model.options.GetAccountDataOptions
@ -9,6 +13,9 @@ import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response
import net.codinux.banking.client.model.tan.EnterTanResult
import net.codinux.banking.client.service.BankingModelService
import net.codinux.banking.client.updateAccountTransactions
import net.codinux.banking.fints.extensions.minusDays
fun main() {
val showUsage = ShowUsage()
@ -27,11 +34,11 @@ class ShowUsage {
fun getAccountDataSimpleExample() {
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback())
val client = FinTs4kBankingClient(SimpleBankingClientCallback())
val response = client.getAccountData()
val response = client.getAccountData(bankCode, loginName, password) // that's all
printReceivedData(response)
printReceivedData(response) // now print retrieved data (save it to database, display it in UI, ...)
}
fun getAccountDataFullExample() {
@ -57,6 +64,28 @@ class ShowUsage {
}
fun updateAccountTransactions() {
val client = FinTs4kBankingClientForUser(bankCode, loginName, password, SimpleBankingClientCallback())
// simulate account transactions we retrieved last time
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val lastCallToBankServer = client.getAccountData(GetAccountDataOptions(RetrieveTransactions.AccordingToRetrieveFromAndTo, retrieveTransactionsFrom = today.minusDays(90), retrieveTransactionsTo = today.minusDays(30)))
if (lastCallToBankServer.data != null) { // now update account transactions
val existingTransactions = lastCallToBankServer.data!!.bookedTransactions
val updateTransactionsResponse = client.updateAccountTransactions()
if (updateTransactionsResponse.data != null) {
val retrievedTransactions = updateTransactionsResponse.data!!.flatMap { it.bookedTransactions }
val newTransactions = BankingModelService().findNewTransactions(retrievedTransactions, existingTransactions)
// `retrievedTransactions` may contain transactions we already have locally, `newTransactions` only
// transactions that are not in `existingTransactions`
}
}
}
private fun printReceivedData(response: Response<GetAccountDataResponse>) {
response.data?.let { data ->
val user = data.user