Compare commits

...

4 Commits

24 changed files with 596 additions and 32 deletions

View File

@ -36,8 +36,8 @@ interface BankingClient {
/** /**
* Convenience wrapper around [getAccountDataAsync]. * Convenience wrapper around [getAccountDataAsync].
* Updates account's transactions beginning from [BankAccount.lastTransactionsRetrievalTime]. * Updates account's transactions beginning from [BankAccount.lastAccountUpdateTime].
* This may requires TAN if [BankAccount.lastTransactionsRetrievalTime] is older than 90 days. * This may requires TAN if [BankAccount.lastAccountUpdateTime] is older than 90 days.
* *
* Optionally specify which [accounts] should be updated. If not specified all accounts will be updated. * Optionally specify which [accounts] should be updated. If not specified all accounts will be updated.
*/ */

View File

@ -37,8 +37,8 @@ interface BankingClientForUser {
/** /**
* Convenience wrapper around [getAccountDataAsync]. * Convenience wrapper around [getAccountDataAsync].
* Updates account's transactions beginning from [BankAccount.lastTransactionsRetrievalTime]. * Updates account's transactions beginning from [BankAccount.lastAccountUpdateTime].
* This may requires TAN if [BankAccount.lastTransactionsRetrievalTime] is older than 90 days. * This may requires TAN if [BankAccount.lastAccountUpdateTime] is older than 90 days.
*/ */
suspend fun updateAccountTransactionsAsync(accounts: List<BankAccount>? = null): Response<List<GetTransactionsResponse>> suspend fun updateAccountTransactionsAsync(accounts: List<BankAccount>? = null): Response<List<GetTransactionsResponse>>

View File

@ -79,6 +79,8 @@ kotlin {
val kotlinxDateTimeVersion: String by project val kotlinxDateTimeVersion: String by project
val jsJodaTimeZoneVersion: String by project val jsJodaTimeZoneVersion: String by project
val ionspinBigNumVersion: String by project
sourceSets { sourceSets {
commonMain { commonMain {
@ -102,12 +104,26 @@ kotlin {
jsMain { jsMain {
dependencies { dependencies {
api(npm("@js-joda/timezone", jsJodaTimeZoneVersion)) api(npm("@js-joda/timezone", jsJodaTimeZoneVersion))
implementation(npm("big.js", "6.0.3"))
} }
} }
jsTest { } jsTest { }
nativeMain { } nativeMain { }
nativeTest { } nativeTest { }
linuxMain {
dependencies {
implementation("com.ionspin.kotlin:bignum:$ionspinBigNumVersion")
}
}
mingwMain {
dependencies {
implementation("com.ionspin.kotlin:bignum:$ionspinBigNumVersion")
}
}
} }
} }

View File

@ -0,0 +1,29 @@
package net.codinux.banking.client.model
import platform.Foundation.NSDecimalNumber
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
actual class Amount actual constructor(amount: String) : NSDecimalNumber(string = amount) {
actual companion object {
actual val Zero = Amount("0.00")
}
actual operator fun plus(other: Amount): Amount =
Amount(this.decimalNumberByAdding(other).stringValue)
actual operator fun minus(other: Amount): Amount =
Amount(this.decimalNumberBySubtracting(other).stringValue)
actual operator fun times(other: Amount): Amount =
Amount(this.decimalNumberByMultiplyingBy(other).stringValue)
actual operator fun div(other: Amount): Amount =
Amount(this.decimalNumberByDividingBy(other).stringValue)
actual override fun toString(): String = this.stringValue
}

View File

@ -1,18 +1,31 @@
package net.codinux.banking.client.model package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor import net.codinux.banking.client.model.config.NoArgConstructor
import kotlin.jvm.JvmInline
@JvmInline
internal const val DecimalPrecision = 20 // 20 to match Big.js's behavior
fun Amount.toFloat() =
this.toString().toFloat()
fun Amount.toDouble() =
this.toString().toDouble()
@NoArgConstructor @NoArgConstructor
value class Amount(val amount: String = "0") { expect class Amount(amount: String = "0") {
companion object { companion object {
val Zero = Amount("0") val Zero: Amount
fun fromString(amount: String): Amount = Amount(amount)
} }
override fun toString() = amount operator fun plus(other: Amount): Amount
operator fun minus(other: Amount): Amount
operator fun times(other: Amount): Amount
operator fun div(other: Amount): Amount
override fun toString(): String
} }

View File

@ -3,6 +3,7 @@ package net.codinux.banking.client.model
import kotlinx.datetime.* import kotlinx.datetime.*
import net.codinux.banking.client.model.config.JsonIgnore import net.codinux.banking.client.model.config.JsonIgnore
import net.codinux.banking.client.model.config.NoArgConstructor import net.codinux.banking.client.model.config.NoArgConstructor
import net.codinux.banking.client.model.securitiesaccount.Holding
@Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED") @Suppress("RUNTIME_ANNOTATION_NOT_SUPPORTED")
@NoArgConstructor @NoArgConstructor
@ -23,11 +24,12 @@ open class BankAccount(
var balance: Amount = Amount.Zero, // TODO: add a BigDecimal library var balance: Amount = Amount.Zero, // TODO: add a BigDecimal library
val serverTransactionsRetentionDays: Int? = null, val serverTransactionsRetentionDays: Int? = null,
open var lastTransactionsRetrievalTime: Instant? = null, open var lastAccountUpdateTime: Instant? = null,
var retrievedTransactionsFrom: LocalDate? = null, var retrievedTransactionsFrom: LocalDate? = null,
open val bookedTransactions: MutableList<AccountTransaction> = mutableListOf(), open val bookedTransactions: MutableList<AccountTransaction> = mutableListOf(),
open val prebookedTransactions: MutableList<PrebookedAccountTransaction> = mutableListOf(), open val prebookedTransactions: MutableList<PrebookedAccountTransaction> = mutableListOf(),
open val holdings: List<Holding> = emptyList(),
var userSetDisplayName: String? = null, var userSetDisplayName: String? = null,
var displayIndex: Int = 0, var displayIndex: Int = 0,

View File

@ -0,0 +1,14 @@
package net.codinux.banking.client.model.extensions
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
fun LocalDate.minusDays(days: Int): LocalDate {
return this.minus(days, DateTimeUnit.DAY)
}
val TimeZone.Companion.EuropeBerlin: TimeZone
get() = TimeZone.of("Europe/Berlin")

View File

@ -7,6 +7,7 @@ import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.BankAccount import net.codinux.banking.client.model.BankAccount
import net.codinux.banking.client.model.PrebookedAccountTransaction import net.codinux.banking.client.model.PrebookedAccountTransaction
import net.codinux.banking.client.model.config.NoArgConstructor import net.codinux.banking.client.model.config.NoArgConstructor
import net.codinux.banking.client.model.securitiesaccount.Holding
@NoArgConstructor @NoArgConstructor
open class GetTransactionsResponse( open class GetTransactionsResponse(
@ -14,6 +15,7 @@ open class GetTransactionsResponse(
val balance: Amount? = null, val balance: Amount? = null,
val bookedTransactions: List<AccountTransaction>, val bookedTransactions: List<AccountTransaction>,
val prebookedTransactions: List<PrebookedAccountTransaction>, val prebookedTransactions: List<PrebookedAccountTransaction>,
val holdings: List<Holding> = emptyList(),
val transactionsRetrievalTime: Instant, val transactionsRetrievalTime: Instant,
val retrievedTransactionsFrom: LocalDate? = null, val retrievedTransactionsFrom: LocalDate? = null,
val retrievedTransactionsTo: LocalDate? = null val retrievedTransactionsTo: LocalDate? = null

View File

@ -0,0 +1,49 @@
package net.codinux.banking.client.model.securitiesaccount
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import net.codinux.banking.client.model.Amount
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
open class Holding(
val name: String,
val isin: String? = null,
val wkn: String? = null,
val quantity: Int? = null,
val currency: String? = null,
/**
* Gesamter Kurswert aller Einheiten des Wertpapiers
*/
val totalBalance: Amount? = null,
/**
* Aktueller Kurswert einer einzelnen Einheit des Wertpapiers
*/
val marketValue: Amount? = null,
/**
* Änderung in Prozent Aktueller Kurswert gegenüber Einstandspreis.
*/
val performancePercentage: Float? = null,
/**
* Gesamter Einstandspreis (Kaufpreis)
*/
val totalCostPrice: Amount? = null,
/**
* (Durchschnittlicher) Einstandspreis/-kurs einer Einheit des Wertpapiers
*/
val averageCostPrice: Amount? = null,
/**
* Zeitpunkt zu dem der Kurswert bestimmt wurde
*/
val pricingTime: Instant? = null,
val buyingDate: LocalDate? = null,
) {
override fun toString() = "$name $totalBalance $currency"
}

View File

@ -0,0 +1,36 @@
package net.codinux.banking.client.model
import kotlin.test.Test
import kotlin.test.assertEquals
class AmountTest {
@Test
fun add() {
val result = Amount("0.1") + Amount("0.2")
assertEquals(Amount("0.3"), result)
}
@Test
fun minus() {
val result = Amount("0.1") - Amount("0.2")
assertEquals(Amount("-0.1"), result)
}
@Test
fun multiply() {
val result = Amount("0.1") * Amount("0.2")
assertEquals(Amount("0.02"), result)
}
@Test
fun divide() {
val result = Amount("1") / Amount("3")
assertEquals(Amount("0.33333333333333333333"), result)
}
}

View File

@ -0,0 +1,51 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
@JsModule("big.js")
@JsNonModule
open external class Big(value: String) {
fun plus(other: Big): Big
fun minus(other: Big): Big
fun times(other: Big): Big
fun div(other: Big): Big
override fun toString(): String
}
@NoArgConstructor
actual class Amount actual constructor(amount: String): Big(amount) {
actual companion object {
actual val Zero = Amount("0.00")
}
actual operator fun plus(other: Amount): Amount {
return Amount(super.plus(other).toString())
}
actual operator fun minus(other: Amount): Amount {
return Amount(super.minus(other).toString())
}
actual operator fun times(other: Amount): Amount {
return Amount(super.times(other).toString())
}
actual operator fun div(other: Amount): Amount {
return Amount(super.div(other).toString())
}
override fun equals(other: Any?): Boolean {
return other is Amount && this.toString() == other.toString()
}
override fun hashCode(): Int {
return super.hashCode()
}
actual override fun toString(): String = super.toString()
}

View File

@ -0,0 +1,40 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
import java.math.BigDecimal
import java.math.RoundingMode
@NoArgConstructor
actual class Amount actual constructor(amount: String) : BigDecimal(amount) {
actual companion object {
actual val Zero = Amount("0.00")
}
actual operator fun plus(other: Amount): Amount =
Amount(this.add(other).toString())
actual operator fun minus(other: Amount): Amount =
Amount(this.subtract(other).toString())
actual operator fun times(other: Amount): Amount =
Amount(this.multiply(other).toString())
actual operator fun div(other: Amount): Amount =
// without RoundingMode a java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result. will be thrown
Amount(this.divide(other, DecimalPrecision, RoundingMode.HALF_UP).toString()) // 20 to match Big.js's behaviour
/* why are these methods required when deriving from BigDecimal? */
override fun toByte(): Byte {
1 + 1
return 0 // will never be called; where is this method coming from?
}
override fun toShort(): Short {
return 0 // will never be called; where is this method coming from?
}
}

View File

@ -0,0 +1,47 @@
package net.codinux.banking.client.model
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.DecimalMode
import com.ionspin.kotlin.bignum.decimal.RoundingMode
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
actual class Amount actual constructor(amount: String) {
actual companion object {
actual val Zero: Amount = Amount("0.00")
private val decimalMode = DecimalMode(DecimalPrecision.toLong(), RoundingMode.ROUND_HALF_CEILING)
}
internal val amount: BigDecimal = BigDecimal.parseString(amount)
actual operator fun plus(other: Amount): Amount {
return Amount(amount.add(other.amount).toString())
}
actual operator fun minus(other: Amount): Amount {
return Amount(amount.subtract(other.amount).toString())
}
actual operator fun times(other: Amount): Amount {
return Amount(amount.multiply(other.amount).toString())
}
actual operator fun div(other: Amount): Amount {
return Amount(amount.divide(other.amount, decimalMode).toString())
}
override fun equals(other: Any?): Boolean {
return other is Amount && this.amount == other.amount
}
override fun hashCode(): Int {
return amount.hashCode()
}
actual override fun toString(): String = amount.toPlainString()
}

View File

@ -0,0 +1,47 @@
package net.codinux.banking.client.model
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.DecimalMode
import com.ionspin.kotlin.bignum.decimal.RoundingMode
import net.codinux.banking.client.model.config.NoArgConstructor
@NoArgConstructor
actual class Amount actual constructor(amount: String) {
actual companion object {
actual val Zero: Amount = Amount("0.00")
private val decimalMode = DecimalMode(DecimalPrecision.toLong(), RoundingMode.ROUND_HALF_CEILING)
}
internal val amount: BigDecimal = BigDecimal.parseString(amount)
actual operator fun plus(other: Amount): Amount {
return Amount(amount.add(other.amount).toString())
}
actual operator fun minus(other: Amount): Amount {
return Amount(amount.subtract(other.amount).toString())
}
actual operator fun times(other: Amount): Amount {
return Amount(amount.multiply(other.amount).toString())
}
actual operator fun div(other: Amount): Amount {
return Amount(amount.divide(other.amount, decimalMode).toString())
}
override fun equals(other: Any?): Boolean {
return other is Amount && this.amount == other.amount
}
override fun hashCode(): Int {
return amount.hashCode()
}
actual override fun toString(): String = amount.toPlainString()
}

View File

@ -0,0 +1,51 @@
package net.codinux.banking.client.model
import net.codinux.banking.client.model.config.NoArgConstructor
@JsModule("big.js")
@kotlin.js.JsNonModule
open external class Big(value: String) {
fun plus(other: Big): Big
fun minus(other: Big): Big
fun times(other: Big): Big
fun div(other: Big): Big
override fun toString(): String
}
@NoArgConstructor
actual class Amount actual constructor(amount: String): Big(amount) {
actual companion object {
actual val Zero = Amount("0.00")
}
actual operator fun plus(other: Amount): Amount {
return Amount(super.plus(other).toString())
}
actual operator fun minus(other: Amount): Amount {
return Amount(super.minus(other).toString())
}
actual operator fun times(other: Amount): Amount {
return Amount(super.times(other).toString())
}
actual operator fun div(other: Amount): Amount {
return Amount(super.div(other).toString())
}
override fun equals(other: Any?): Boolean {
return other is Amount && this.toString() == other.toString()
}
override fun hashCode(): Int {
return super.hashCode()
}
actual override fun toString(): String = super.toString()
}

View File

@ -10,7 +10,7 @@ plugins {
kotlin { kotlin {
jvmToolchain(8) jvmToolchain(11)
jvm { jvm {
withJava() withJava()
@ -77,7 +77,7 @@ kotlin {
dependencies { dependencies {
api(project(":BankingClient")) api(project(":BankingClient"))
api("net.codinux.banking:fints4k:1.0.0-Alpha-13-SNAPSHOT") implementation("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

@ -20,6 +20,9 @@ open class FinTs4kBankingClient(
constructor(callback: BankingClientCallback) : this(FinTsClientConfiguration(), callback) constructor(callback: BankingClientCallback) : this(FinTsClientConfiguration(), callback)
constructor(options: FinTsClientOptions, callback: BankingClientCallback)
: this(FinTsClientConfiguration(net.codinux.banking.fints.config.FinTsClientOptions(options.collectMessageLog, false, options.removeSensitiveDataFromMessageLog, options.closeDialogs, options.version, options.productName)), callback)
protected open val mapper = FinTs4kMapper() protected open val mapper = FinTs4kMapper()

View File

@ -1,11 +1,13 @@
package net.codinux.banking.client.fints4k package net.codinux.banking.client.fints4k
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime 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
import net.codinux.banking.client.model.extensions.EuropeBerlin
import net.codinux.banking.client.model.tan.* import net.codinux.banking.client.model.tan.*
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
@ -16,7 +18,6 @@ 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
@ -24,6 +25,7 @@ import net.dankito.banking.client.model.response.ErrorCode
import net.codinux.banking.fints.mapper.FinTsModelMapper import net.codinux.banking.fints.mapper.FinTsModelMapper
import net.codinux.banking.fints.messages.datenelemente.implementierte.signatur.Sicherheitsfunktion import net.codinux.banking.fints.messages.datenelemente.implementierte.signatur.Sicherheitsfunktion
import net.codinux.banking.fints.model.* import net.codinux.banking.fints.model.*
import net.codinux.banking.fints.transactions.swift.model.Holding
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.MobilePhoneTanMedium import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.MobilePhoneTanMedium
import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium import net.codinux.banking.fints.messages.datenelemente.implementierte.tan.TanGeneratorTanMedium
@ -60,7 +62,7 @@ open class FinTs4kMapper {
val defaults = GetAccountDataOptions() val defaults = GetAccountDataOptions()
val accountIdentifier = BankAccountIdentifierImpl(account.identifier, account.subAccountNumber, account.iban) 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 from = account.lastAccountUpdateTime?.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.valueOf(defaults.retrieveTransactions.name) val retrieveTransactions = if (from != null) RetrieveTransactions.AccordingToRetrieveFromAndTo else RetrieveTransactions.valueOf(defaults.retrieveTransactions.name)
// 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 = listOf(mapTanMethodType(user.selectedTanMethod.type)) // TODO: currently we aren't saving TanMethods in database, re-enable as soon as TanMethods get saved
val preferredTanMethods = defaults.preferredTanMethods?.map { mapTanMethodType(it) } val preferredTanMethods = defaults.preferredTanMethods?.map { mapTanMethodType(it) }
@ -103,16 +105,17 @@ open class FinTs4kMapper {
val finTsBankAccount = user?.accounts?.firstOrNull { it.identifier == account.identifier && it.subAccountNumber == account.subAccountNumber } val finTsBankAccount = user?.accounts?.firstOrNull { it.identifier == account.identifier && it.subAccountNumber == account.subAccountNumber }
if (getAccountDataResponse.successful && user != null && finTsBankAccount != null) { if (getAccountDataResponse.successful && user != null && finTsBankAccount != null) {
if (finTsBankAccount.lastTransactionsRetrievalTime != null) { if (finTsBankAccount.lastAccountUpdateTime != null) {
account.lastTransactionsRetrievalTime = finTsBankAccount.lastTransactionsRetrievalTime account.lastAccountUpdateTime = finTsBankAccount.lastAccountUpdateTime
} }
if (account.retrievedTransactionsFrom == null || (finTsBankAccount.retrievedTransactionsFrom != null if (account.retrievedTransactionsFrom == null || (finTsBankAccount.retrievedTransactionsFrom != null
&& account.retrievedTransactionsFrom!! < finTsBankAccount.retrievedTransactionsFrom!!)) { && account.retrievedTransactionsFrom!! < finTsBankAccount.retrievedTransactionsFrom!!)) {
account.retrievedTransactionsFrom = finTsBankAccount.retrievedTransactionsFrom account.retrievedTransactionsFrom = finTsBankAccount.retrievedTransactionsFrom
} }
Response.success(GetTransactionsResponse(account, mapAmount(finTsBankAccount.balance), mapBookedTransactions(finTsBankAccount), emptyList(), Response.success(GetTransactionsResponse(account, mapMoney(finTsBankAccount.balance), mapBookedTransactions(finTsBankAccount), emptyList(),
finTsBankAccount.lastTransactionsRetrievalTime ?: Clock.System.now(), param.retrieveTransactionsFrom, param.retrieveTransactionsTo)) mapHoldings(finTsBankAccount.statementOfHoldings, finTsBankAccount.currency, finTsBankAccount.lastAccountUpdateTime),
finTsBankAccount.lastAccountUpdateTime ?: Clock.System.now(), param.retrieveTransactionsFrom, param.retrieveTransactionsTo))
} else { } else {
mapError(getAccountDataResponse) mapError(getAccountDataResponse)
} }
@ -156,10 +159,11 @@ open class FinTs4kMapper {
account.identifier, account.subAccountNumber, account.iban, account.productName, account.accountHolderName, account.identifier, account.subAccountNumber, account.iban, account.productName, account.accountHolderName,
mapAccountType(account.type), account.currency, account.accountLimit, mapAccountType(account.type), account.currency, account.accountLimit,
account.isAccountTypeSupportedByApplication, mapFeatures(account), account.isAccountTypeSupportedByApplication, mapFeatures(account),
mapAmount(account.balance), mapMoney(account.balance),
account.serverTransactionsRetentionDays, account.serverTransactionsRetentionDays,
account.lastTransactionsRetrievalTime, account.retrievedTransactionsFrom, account.lastAccountUpdateTime, account.retrievedTransactionsFrom,
bookedTransactions = mapBookedTransactions(account).toMutableList() bookedTransactions = mapBookedTransactions(account).toMutableList(),
holdings = mapHoldings(account.statementOfHoldings, account.currency, account.lastAccountUpdateTime)
) )
protected open fun mapAccountType(type: net.dankito.banking.client.model.BankAccountType): BankAccountType = protected open fun mapAccountType(type: net.dankito.banking.client.model.BankAccountType): BankAccountType =
@ -185,12 +189,12 @@ open class FinTs4kMapper {
account.bookedTransactions.map { mapTransaction(it) } 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.reference, mapMoney(transaction.amount), transaction.amount.currency.code, transaction.reference,
transaction.bookingDate, transaction.valueDate, transaction.bookingDate, transaction.valueDate,
transaction.otherPartyName, transaction.otherPartyBankId, transaction.otherPartyAccountId, transaction.otherPartyName, transaction.otherPartyBankId, transaction.otherPartyAccountId,
transaction.postingText, transaction.postingText,
mapNullableAmount(transaction.openingBalance), mapNullableAmount(transaction.closingBalance), mapNullableMoney(transaction.openingBalance), mapNullableMoney(transaction.closingBalance),
transaction.statementNumber, transaction.sheetNumber, transaction.statementNumber, transaction.sheetNumber,
@ -209,9 +213,77 @@ open class FinTs4kMapper {
transaction.isReversal transaction.isReversal
) )
protected open fun mapNullableAmount(amount: Money?) = amount?.let { mapAmount(it) } protected open fun mapHoldings(statements: List<net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings>, accountCurrency: String, lastAccountUpdateTime: Instant? = null) =
statements.flatMap { mapHoldings(it, accountCurrency, lastAccountUpdateTime) }
protected open fun mapAmount(amount: Money) = Amount.fromString(amount.amount.string.replace(',', '.')) protected open fun mapHoldings(statement: net.codinux.banking.fints.transactions.swift.model.StatementOfHoldings, accountCurrency: String, lastAccountUpdateTime: Instant? = null): List<net.codinux.banking.client.model.securitiesaccount.Holding> {
val totalBalance = mapNullableAmount(statement.totalBalance)
val currency = statement.currency ?: accountCurrency
val statementDate: Instant? = /* statement.statementDate ?: statement.preparationDate ?: */ lastAccountUpdateTime // TODO
return statement.holdings.map { mapHolding(it, currency, statementDate, if (statement.holdings.size == 1) totalBalance else null) }
}
protected open fun mapHolding(holding: Holding, accountCurrency: String, statementDate: Instant?, totalBalance: Amount? = null) = net.codinux.banking.client.model.securitiesaccount.Holding(
holding.name, holding.isin, holding.wkn,
holding.quantity, holding.currency ?: accountCurrency,
getTotalBalance(holding), mapNullableAmount(holding.marketValue),
calculatePerformance(holding),
getTotalCostPrice(holding), mapNullableAmount(holding.averageCostPrice),
holding.pricingTime ?: statementDate, holding.buyingDate
)
// visible for testing
internal fun getTotalBalance(holding: Holding): Amount? {
return if (holding.totalBalance != null) {
mapNullableAmount(holding.totalBalance)
} else if (holding.quantity != null && holding.marketValue != null) {
Amount((holding.quantity!! * holding.marketValue.toString().toDouble()).toString())
} else {
null
}
}
// visible for testing
internal fun getTotalCostPrice(holding: Holding): Amount? {
return if (holding.totalCostPrice != null) {
mapNullableAmount(holding.totalCostPrice)
} else if (holding.quantity != null && holding.averageCostPrice != null) {
Amount((holding.quantity!! * holding.averageCostPrice.toString().toDouble()).toString())
} else {
null
}
}
// visible for testing
internal fun calculatePerformance(holding: Holding): Float? {
val totalBalance = getTotalBalance(holding)
val totalCostPrice = getTotalCostPrice(holding)
if (totalBalance != null && totalCostPrice != null) {
return ((totalBalance - totalCostPrice) / totalCostPrice).toFloat() * 100
}
val marketValue = mapNullableAmount(holding.marketValue)
val costPrice = mapNullableAmount(holding.averageCostPrice)
if (marketValue != null && costPrice != null) {
return ((marketValue - costPrice) / costPrice).toFloat() * 100
}
return null
}
protected open fun mapNullableMoney(amount: Money?) = amount?.let { mapMoney(it) }
protected open fun mapMoney(amount: Money) = Amount(amount.amount.string.replace(',', '.'))
protected open fun mapNullableAmount(amount: net.codinux.banking.fints.model.Amount?) = amount?.let { mapAmount(it) }
protected open fun mapAmount(amount: net.codinux.banking.fints.model.Amount) = Amount(amount.string.replace(',', '.'))
open fun mapTanChallenge(challenge: net.codinux.banking.fints.model.TanChallenge): TanChallenge { open fun mapTanChallenge(challenge: net.codinux.banking.fints.model.TanChallenge): TanChallenge {
@ -318,7 +390,7 @@ open class FinTs4kMapper {
mapError(response) mapError(response)
} }
open fun mapToMoney(amount: Amount, currency: String): Money = Money(amount.amount, currency) open fun mapToMoney(amount: Amount, currency: String): Money = Money(amount.toString(), currency)
protected open fun <T> mapError(response: net.dankito.banking.client.model.response.FinTsClientResponse): Response<T> { protected open fun <T> mapError(response: net.dankito.banking.client.model.response.FinTsClientResponse): Response<T> {

View File

@ -0,0 +1,31 @@
package net.codinux.banking.client.fints4k
data class FinTsClientOptions(
/**
* If FinTS messages sent to and received from bank servers and errors should be collected.
*
* Set to false by default.
*/
val collectMessageLog: Boolean = false,
// /**
// * If set to true then [net.codinux.banking.fints.callback.FinTsClientCallback.messageLogAdded] get fired when a
// * FinTS message get sent to bank server, a FinTS message is received from bank server or an error occurred.
// *
// * Defaults to false.
// */
// val fireCallbackOnMessageLogs: Boolean = false,
/**
* If sensitive data like user name, password, login name should be removed from FinTS messages before being logged.
*
* Defaults to true.
*/
val removeSensitiveDataFromMessageLog: Boolean = true,
val closeDialogs: Boolean = true,
val version: String = "1.0.0", // TODO: get version dynamically
val productName: String = "15E53C26816138699C7B6A3E8"
)

View File

@ -0,0 +1,53 @@
package net.codinux.banking.client.fints4k
import net.codinux.banking.client.model.Amount
import kotlin.test.Test
import net.codinux.banking.fints.transactions.swift.model.Holding
import kotlin.test.assertEquals
class FinTs4kMapperTest {
private val underTest = FinTs4kMapper()
@Test
fun getTotalBalance_TotalBalanceIsNull_CalculateByQuantityAndMarketValue() {
val holding = Holding("", null, null, null, 4, null, null, null, fints4kAmount("13.33"))
val result = underTest.getTotalBalance(holding)
assertEquals(Amount("53.32"), result)
}
@Test
fun getTotalCostPrice_TotalCostPriceIsNull_CalculateByQuantityAndAverageCostPrice() {
val holding = Holding("", null, null, null, 47, fints4kAmount("16.828"), null)
val result = underTest.getTotalCostPrice(holding)
assertEquals(Amount("790.9159999999999"), result)
}
@Test
fun calculatePerformance_ByTotalBalanceAndTotalCostPrice() {
val holding = Holding("", null, null, null, null, null, fints4kAmount("20217.12"), null, null, null, fints4kAmount("19027.04"))
val result = underTest.calculatePerformance(holding)
assertEquals(6.2546773f, result!!, 0.000001f) // for JS the result has too many decimal places, so add a tolerance
}
@Test
fun calculatePerformance_ByMarketValueAndAverageCostPrice() {
val holding = Holding("", null, null, null, null, fints4kAmount("16.828"), null, null, fints4kAmount("16.75"), null, null)
val result = underTest.calculatePerformance(holding)
assertEquals(-0.4635132f, result!!, 0.0000001f) // for JS the result has too many decimal places, so add a tolerance
}
private fun fints4kAmount(amount: String) =
net.codinux.banking.fints.model.Amount(amount)
}

View File

@ -115,12 +115,12 @@ response.error?.let{ error ->
### Update Account Transactions ### Update Account Transactions
The data model saves when it retrieved account transactions the last time (in `BankAccount.lastTransactionsRetrievalTime`). The data model saves when it retrieved account transactions the last time (in `BankAccount.lastAccountUpdateTime`).
So you only need to call `FinTs4kBankingClient.updateAccountTransactions()` to retrieve all transactions starting from So you only need to call `FinTs4kBankingClient.updateAccountTransactions()` to retrieve all transactions starting from
`BankAccount.lastTransactionsRetrievalTime`. `BankAccount.lastAccountUpdateTime`.
But as we can only specify from which day on account transactions should be retrieved, response may contain some transactions 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 from the day of `lastAccountUpdateTime` that we already have locally. To filter out these you can use
`BankingModelService().findNewTransactions(retrieveTransactions, existingTransactions)`: `BankingModelService().findNewTransactions(retrieveTransactions, existingTransactions)`:
```kotlin ```kotlin

View File

@ -15,7 +15,7 @@ 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.service.BankingModelService
import net.codinux.banking.client.updateAccountTransactions import net.codinux.banking.client.updateAccountTransactions
import net.codinux.banking.fints.extensions.minusDays import net.codinux.banking.client.model.extensions.minusDays
fun main() { fun main() {
val showUsage = ShowUsage() val showUsage = ShowUsage()

View File

@ -6,3 +6,6 @@ kotlinVersion=2.0.10
kotlinxDateTimeVersion=0.5.0 kotlinxDateTimeVersion=0.5.0
jsJodaTimeZoneVersion=2.3.0 jsJodaTimeZoneVersion=2.3.0
coroutinesVersion=1.8.1 coroutinesVersion=1.8.1
# 0.3.10 uses Kotlin 2.0.0
ionspinBigNumVersion=0.3.9

View File

@ -556,6 +556,11 @@ batch@0.6.1:
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
big.js@6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-6.0.3.tgz#8b4d99ac7023668e0e465d3f78c23b8ac29ad381"
integrity sha512-n6yn1FyVL1EW2DBAr4jlU/kObhRzmr+NNRESl65VIOT8WBJj/Kezpx2zFdhJUqYI6qrtTW7moCStYL5VxeVdPA==
binary-extensions@^2.0.0: binary-extensions@^2.0.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"