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 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.options.RetrieveTransactions
import net.codinux.banking.client.model.request.GetAccountDataRequest import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.GetTransactionsResponse
import net.codinux.banking.client.model.response.Response import net.codinux.banking.client.model.response.Response
interface BankingClient { interface BankingClient {
@ -30,4 +32,11 @@ interface BankingClient {
*/ */
suspend fun getAccountDataAsync(request: GetAccountDataRequest): Response<GetAccountDataResponse> 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 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.GetAccountDataOptions
import net.codinux.banking.client.model.options.RetrieveTransactions import net.codinux.banking.client.model.options.RetrieveTransactions
import net.codinux.banking.client.model.response.GetAccountDataResponse import net.codinux.banking.client.model.response.GetAccountDataResponse
import net.codinux.banking.client.model.response.GetTransactionsResponse
import net.codinux.banking.client.model.response.Response import net.codinux.banking.client.model.response.Response
interface BankingClientForUser { interface BankingClientForUser {
@ -30,4 +32,11 @@ interface BankingClientForUser {
*/ */
suspend fun getAccountDataAsync(options: GetAccountDataOptions): Response<GetAccountDataResponse> 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 package net.codinux.banking.client
import net.codinux.banking.client.model.AccountCredentials 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.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest 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( abstract class BankingClientForUserBase(
protected val credentials: AccountCredentials, protected val credentials: AccountCredentials,
protected val client: BankingClient protected val client: BankingClient
) : BankingClientForUser { ) : BankingClientForUser {
private lateinit var user: UserAccount
override suspend fun getAccountDataAsync(options: GetAccountDataOptions) = 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 package net.codinux.banking.client
import kotlinx.coroutines.runBlocking 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.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest import net.codinux.banking.client.model.request.GetAccountDataRequest
@ -12,10 +14,19 @@ fun BankingClient.getAccountData(request: GetAccountDataRequest) = runBlocking {
this@getAccountData.getAccountDataAsync(request) this@getAccountData.getAccountDataAsync(request)
} }
fun BankingClient.updateAccountTransactions(user: UserAccount, accounts: List<BankAccount>? = null) = runBlocking {
this@updateAccountTransactions.updateAccountTransactionsAsync(user, accounts)
}
fun BankingClientForUser.getAccountData() = runBlocking { fun BankingClientForUser.getAccountData() = runBlocking {
this@getAccountData.getAccountDataAsync() this@getAccountData.getAccountDataAsync()
} }
fun BankingClientForUser.getAccountData(options: GetAccountDataOptions) = runBlocking { fun BankingClientForUser.getAccountData(options: GetAccountDataOptions) = runBlocking {
this@getAccountData.getAccountDataAsync(options) 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( open class AccountTransaction(
val amount: Amount = Amount.Zero, val amount: Amount = Amount.Zero,
val currency: String, 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. * 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 category: String? = null,
var notes: 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" 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 package net.codinux.banking.client.model
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor @NoArgConstructor
@ -14,13 +16,14 @@ open class BankAccount(
val currency: String = "EUR", val currency: String = "EUR",
var accountLimit: String? = null, var accountLimit: String? = null,
val isAccountTypeSupportedByApplication: Boolean = true,
val features: Set<BankAccountFeatures> = emptySet(),
// var balance: BigDecimal = BigDecimal.ZERO, // var balance: BigDecimal = BigDecimal.ZERO,
var balance: Amount = Amount.Zero, // TODO: add a BigDecimal library 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 retrievedTransactionsFrom: LocalDate? = null,
var retrievedTransactionsTo: LocalDate? = null,
var haveAllTransactionsBeenRetrieved: Boolean = false, var haveAllTransactionsBeenRetrieved: Boolean = false,
val countDaysForWhichTransactionsAreKept: Int? = null, val countDaysForWhichTransactionsAreKept: Int? = null,
@ -34,5 +37,12 @@ open class BankAccount(
var hideAccount: Boolean = false, var hideAccount: Boolean = false,
var includeInAutomaticAccountsUpdate: Boolean = true 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)" override fun toString() = "$type $identifier $productName (IBAN: $iban)"
} }

View File

@ -51,6 +51,11 @@ open class UserAccount(
var displayIndex: Int = 0 var displayIndex: Int = 0
@get:JsonIgnore
open val displayName: String
get() = userSetDisplayName ?: bankName
@get:JsonIgnore @get:JsonIgnore
val selectedTanMethod: TanMethod val selectedTanMethod: TanMethod
get() = tanMethods.first { it.identifier == selectedTanMethodId } 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") @Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor @NoArgConstructor
open class TanChallenge( open class TanChallenge(
val type: TanChallengeType, open val type: TanChallengeType,
val forAction: ActionRequiringTan, open val forAction: ActionRequiringTan,
val messageToShowToUser: String, open val messageToShowToUser: String,
/** /**
* Identifier of selected TanMethod. * 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 * 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. * [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 * When adding an account, frontend has no UserAccount object in BankingClientCallback to know which TanMethods are
* available for User. * 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. * 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. * 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 * 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. * [selectedTanMedium] to get selected TanMedium or iterate over [availableTanMedia] and filter selected one by this medium name.
*/ */
val selectedTanMediumName: String? = null, open val selectedTanMediumName: String? = null,
val availableTanMedia: List<TanMedium> = emptyList(), open val availableTanMedia: List<TanMedium> = emptyList(),
val tanImage: TanImage? = null, open val tanImage: TanImage? = null,
val flickerCode: FlickerCode? = null, open val flickerCode: FlickerCode? = null,
val user: UserAccountViewInfo, open val user: UserAccountViewInfo,
val account: BankAccountViewInfo? = null open val account: BankAccountViewInfo? = null
) { ) {
@get:JsonIgnore @get:JsonIgnore
val selectedTanMethod: TanMethod open val selectedTanMethod: TanMethod
get() = availableTanMethods.first { it.identifier == selectedTanMethodId } get() = availableTanMethods.first { it.identifier == selectedTanMethodId }
@get:JsonIgnore @get:JsonIgnore
val selectedTanMedium: TanMedium? open val selectedTanMedium: TanMedium?
get() = availableTanMedia.firstOrNull { it.mediumName == selectedTanMediumName } 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 { override fun toString(): String {
return "$selectedTanMethod $forAction: $messageToShowToUser" + when (type) { return "$selectedTanMethod $forAction: $messageToShowToUser" + when (type) {
TanChallengeType.EnterTan -> "" TanChallengeType.EnterTan -> ""

View File

@ -4,6 +4,10 @@ import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.config.NoArgConstructor import net.codinux.banking.client.model.config.NoArgConstructor
@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( open class TanGeneratorTanMedium(
val cardNumber: String, val cardNumber: String,
val cardSequenceNumber: String? = null, val cardSequenceNumber: String? = null,

View File

@ -16,5 +16,49 @@ open class TanMedium(
*/ */
val mobilePhone: MobilePhoneTanMedium? = null 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" override fun toString() = "$mediumName $status"
} }

View File

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

View File

@ -17,22 +17,30 @@ enum class TanMethodType {
AppTan, AppTan,
DecoupledTan,
DecoupledPushTan,
photoTan, photoTan,
QrCode QrCode
; ;
val isDecoupledMethod: Boolean
get() = this == DecoupledTan || this == DecoupledPushTan
companion object { companion object {
val NonVisual = listOf(TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan) val NonVisual = listOf(TanMethodType.DecoupledTan, TanMethodType.DecoupledPushTan, TanMethodType.AppTan, TanMethodType.SmsTan, TanMethodType.ChipTanManuell, TanMethodType.EnterTan)
val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode) val ImageBased = listOf(TanMethodType.QrCode, TanMethodType.ChipTanQrCode, TanMethodType.photoTan, TanMethodType.ChipTanPhotoTanMatrixCode)
val NonVisualOrImageBased = buildList { 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(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 { dependencies {
api(project(":BankingClient")) 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") 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.BankingClient
import net.codinux.banking.client.BankingClientCallback 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.options.GetAccountDataOptions
import net.codinux.banking.client.model.request.GetAccountDataRequest import net.codinux.banking.client.model.request.GetAccountDataRequest
import net.codinux.banking.client.model.response.GetAccountDataResponse import net.codinux.banking.client.model.response.*
import net.codinux.banking.client.model.response.Response
import net.codinux.banking.fints.FinTsClient import net.codinux.banking.fints.FinTsClient
import net.codinux.banking.fints.config.FinTsClientConfiguration import net.codinux.banking.fints.config.FinTsClientConfiguration
import net.codinux.banking.fints.model.BankData
open class FinTs4kBankingClient( open class FinTs4kBankingClient(
config: FinTsClientConfiguration = FinTsClientConfiguration(), config: FinTsClientConfiguration = FinTsClientConfiguration(),
@ -28,4 +31,28 @@ open class FinTs4kBankingClient(
return mapper.map(response) 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.client.model.AccountCredentials
import net.codinux.banking.fints.config.FinTsClientConfiguration 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)) { : BankingClientForUserBase(credentials, FinTs4kBankingClient(config, callback)) {
constructor(bankCode: String, loginName: String, password: String, callback: BankingClientCallback) constructor(bankCode: String, loginName: String, password: String, callback: BankingClientCallback)

View File

@ -1,5 +1,8 @@
package net.codinux.banking.client.fints4k 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.*
import net.codinux.banking.client.model.AccountTransaction import net.codinux.banking.client.model.AccountTransaction
import net.codinux.banking.client.model.Amount 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.TanImage
import net.codinux.banking.client.model.tan.TanMethod import net.codinux.banking.client.model.tan.TanMethod
import net.codinux.banking.client.model.tan.TanMethodType 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.BankAccountIdentifierImpl
import net.dankito.banking.client.model.parameter.GetAccountDataParameter import net.dankito.banking.client.model.parameter.GetAccountDataParameter
import net.dankito.banking.client.model.parameter.RetrieveTransactions import net.dankito.banking.client.model.parameter.RetrieveTransactions
@ -41,6 +45,21 @@ open class FinTs4kMapper {
abortIfTanIsRequired = options.abortIfTanIsRequired 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 = protected open fun mapTanMethodType(type: TanMethodType): net.codinux.banking.fints.model.TanMethodType =
net.codinux.banking.fints.model.TanMethodType.valueOf(type.name) 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( open fun mapToUserAccountViewInfo(bank: BankData): UserAccountViewInfo = UserAccountViewInfo(
bank.bankCode, bank.customerId, bank.bankName, getBankingGroup(bank.bankName, bank.bic) 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( protected open fun mapAccount(account: net.dankito.banking.client.model.BankAccount) = BankAccount(
account.identifier, account.accountHolderName, mapAccountType(account.type), account.iban, account.subAccountNumber, account.identifier, account.accountHolderName, mapAccountType(account.type), account.iban, account.subAccountNumber,
account.productName, account.currency, account.accountLimit, account.isAccountTypeSupportedByApplication, account.productName, account.currency, account.accountLimit, mapAmount(account.balance),
mapFeatures(account), account.isAccountTypeSupportedByApplication, mapFeatures(account),
mapAmount(account.balance), account.retrievedTransactionsFrom, account.retrievedTransactionsTo, account.lastTransactionsRetrievalTime, account.retrievedTransactionsFrom,
// TODO: map haveAllTransactionsBeenRetrieved // TODO: map haveAllTransactionsBeenRetrieved
countDaysForWhichTransactionsAreKept = account.countDaysForWhichTransactionsAreKept, 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 = 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( protected open fun mapTransaction(transaction: net.dankito.banking.client.model.AccountTransaction): AccountTransaction = AccountTransaction(
mapAmount(transaction.amount), transaction.amount.currency.code, transaction.unparsedReference, mapAmount(transaction.amount), transaction.amount.currency.code, transaction.unparsedReference,
transaction.bookingDate, transaction.valueDate, transaction.bookingDate, transaction.valueDate,
@ -144,7 +195,8 @@ open class FinTs4kMapper {
val selectedTanMethodId = challenge.tanMethod.securityFunction.code val selectedTanMethodId = challenge.tanMethod.securityFunction.code
val tanMedia = challenge.bank.tanMedia.map { mapTanMedium(it) } 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 user = mapToUserAccountViewInfo(challenge.bank)
val account = challenge.account?.let { mapToBankAccountViewInfo(it) } 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 tanImage = if (challenge is ImageTanChallenge) mapTanImage(challenge.image) else null
val flickerCode = if (challenge is FlickerCodeTanChallenge) mapFlickerCode(challenge.flickerCode) 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 { 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() { 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. 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: You can also specify options e.g. which transactions should be retrieved:
```kotlin ```kotlin
@ -104,10 +95,53 @@ val options = GetAccountDataOptions(
val response = client.getAccountData(options) 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`: Add some error handling by checking `response.error`:
```kotlin ```kotlin
response.error?.let{ error -> response.error?.let{ error ->
println("Could not fetch account data: ${error.internalError ?: error.errorMessagesFromBank.joinToString()}") 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 { 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 package net.codinux.banking.client.fints4k.example
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.codinux.banking.client.SimpleBankingClientCallback 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.fints4k.FinTs4kBankingClientForUser
import net.codinux.banking.client.getAccountData import net.codinux.banking.client.getAccountData
import net.codinux.banking.client.model.options.GetAccountDataOptions 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.GetAccountDataResponse
import net.codinux.banking.client.model.response.Response import net.codinux.banking.client.model.response.Response
import net.codinux.banking.client.model.tan.EnterTanResult 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() { fun main() {
val showUsage = ShowUsage() val showUsage = ShowUsage()
@ -27,11 +34,11 @@ class ShowUsage {
fun getAccountDataSimpleExample() { 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() { 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>) { private fun printReceivedData(response: Response<GetAccountDataResponse>) {
response.data?.let { data -> response.data?.let { data ->
val user = data.user val user = data.user