diff --git a/BankingClientModel/build.gradle.kts b/BankingClientModel/build.gradle.kts index 25aae74b..24fa0d53 100644 --- a/BankingClientModel/build.gradle.kts +++ b/BankingClientModel/build.gradle.kts @@ -79,6 +79,8 @@ kotlin { val kotlinxDateTimeVersion: String by project val jsJodaTimeZoneVersion: String by project + val ionspinBigNumVersion: String by project + sourceSets { commonMain { @@ -102,12 +104,26 @@ kotlin { jsMain { dependencies { api(npm("@js-joda/timezone", jsJodaTimeZoneVersion)) + + implementation(npm("big.js", "6.0.3")) } } jsTest { } nativeMain { } nativeTest { } + + linuxMain { + dependencies { + implementation("com.ionspin.kotlin:bignum:$ionspinBigNumVersion") + } + } + + mingwMain { + dependencies { + implementation("com.ionspin.kotlin:bignum:$ionspinBigNumVersion") + } + } } } diff --git a/BankingClientModel/src/appleMain/kotlin/net/codinux/banking/client/model/Amount.apple.kt b/BankingClientModel/src/appleMain/kotlin/net/codinux/banking/client/model/Amount.apple.kt new file mode 100644 index 00000000..28010e65 --- /dev/null +++ b/BankingClientModel/src/appleMain/kotlin/net/codinux/banking/client/model/Amount.apple.kt @@ -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 + +} \ No newline at end of file diff --git a/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/Amount.kt b/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/Amount.kt index bd217748..5e240bde 100644 --- a/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/Amount.kt +++ b/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/Amount.kt @@ -1,21 +1,31 @@ package net.codinux.banking.client.model 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 -value class Amount(val amount: String = "0") { +expect class Amount(amount: String = "0") { companion object { - val Zero = Amount("0") - - fun fromString(amount: String): Amount = Amount(amount) + val Zero: Amount } - constructor(amount: Double) : this(amount.toString()) + 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 - override fun toString() = amount } \ No newline at end of file diff --git a/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/extensions/AmountExtensions.kt b/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/extensions/AmountExtensions.kt deleted file mode 100644 index 025826f7..00000000 --- a/BankingClientModel/src/commonMain/kotlin/net/codinux/banking/client/model/extensions/AmountExtensions.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.codinux.banking.client.model.extensions - -import net.codinux.banking.client.model.Amount - -// TODO: really map to BigDecimal -fun Amount.toBigDecimal(): Double = this.amount.toDouble() \ No newline at end of file diff --git a/BankingClientModel/src/commonTest/kotlin/net/codinux/banking/client/model/AmountTest.kt b/BankingClientModel/src/commonTest/kotlin/net/codinux/banking/client/model/AmountTest.kt new file mode 100644 index 00000000..6a7e28aa --- /dev/null +++ b/BankingClientModel/src/commonTest/kotlin/net/codinux/banking/client/model/AmountTest.kt @@ -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) + } + +} \ No newline at end of file diff --git a/BankingClientModel/src/jsMain/kotlin/net/codinux/banking/client/model/Amount.js.kt b/BankingClientModel/src/jsMain/kotlin/net/codinux/banking/client/model/Amount.js.kt new file mode 100644 index 00000000..25981e7d --- /dev/null +++ b/BankingClientModel/src/jsMain/kotlin/net/codinux/banking/client/model/Amount.js.kt @@ -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() + +} \ No newline at end of file diff --git a/BankingClientModel/src/jvmMain/kotlin/net/codinux/banking/client/model/Amount.jvm.kt b/BankingClientModel/src/jvmMain/kotlin/net/codinux/banking/client/model/Amount.jvm.kt new file mode 100644 index 00000000..6ac8ffbd --- /dev/null +++ b/BankingClientModel/src/jvmMain/kotlin/net/codinux/banking/client/model/Amount.jvm.kt @@ -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? + } + +} \ No newline at end of file diff --git a/BankingClientModel/src/linuxMain/kotlin/net/codinux/banking/client/model/Amount.linux.kt b/BankingClientModel/src/linuxMain/kotlin/net/codinux/banking/client/model/Amount.linux.kt new file mode 100644 index 00000000..0794807e --- /dev/null +++ b/BankingClientModel/src/linuxMain/kotlin/net/codinux/banking/client/model/Amount.linux.kt @@ -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() + +} \ No newline at end of file diff --git a/BankingClientModel/src/mingwMain/kotlin/net/codinux/banking/client/model/Amount.mingw.kt b/BankingClientModel/src/mingwMain/kotlin/net/codinux/banking/client/model/Amount.mingw.kt new file mode 100644 index 00000000..0794807e --- /dev/null +++ b/BankingClientModel/src/mingwMain/kotlin/net/codinux/banking/client/model/Amount.mingw.kt @@ -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() + +} \ No newline at end of file diff --git a/BankingClientModel/src/wasmJsMain/kotlin/net/codinux/banking/client/model/Amount.wasmJs.kt b/BankingClientModel/src/wasmJsMain/kotlin/net/codinux/banking/client/model/Amount.wasmJs.kt new file mode 100644 index 00000000..e26ba15f --- /dev/null +++ b/BankingClientModel/src/wasmJsMain/kotlin/net/codinux/banking/client/model/Amount.wasmJs.kt @@ -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() + +} \ No newline at end of file diff --git a/FinTs4jBankingClient/build.gradle.kts b/FinTs4jBankingClient/build.gradle.kts index bb2597b8..cb571522 100644 --- a/FinTs4jBankingClient/build.gradle.kts +++ b/FinTs4jBankingClient/build.gradle.kts @@ -10,7 +10,7 @@ plugins { kotlin { - jvmToolchain(8) + jvmToolchain(11) jvm { withJava() diff --git a/FinTs4jBankingClient/src/commonMain/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapper.kt b/FinTs4jBankingClient/src/commonMain/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapper.kt index 753c3e81..cb169281 100644 --- a/FinTs4jBankingClient/src/commonMain/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapper.kt +++ b/FinTs4jBankingClient/src/commonMain/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapper.kt @@ -8,7 +8,6 @@ import net.codinux.banking.client.model.* import net.codinux.banking.client.model.AccountTransaction import net.codinux.banking.client.model.Amount import net.codinux.banking.client.model.extensions.EuropeBerlin -import net.codinux.banking.client.model.extensions.toBigDecimal import net.codinux.banking.client.model.tan.* import net.codinux.banking.client.model.options.GetAccountDataOptions import net.codinux.banking.client.model.request.GetAccountDataRequest @@ -238,38 +237,41 @@ open class FinTs4kMapper { holding.pricingTime ?: statementDate, holding.buyingDate ) - private fun getTotalBalance(holding: Holding): Amount? { + // 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!! * mapAmount(holding.marketValue!!).toBigDecimal()) + Amount((holding.quantity!! * holding.marketValue.toString().toDouble()).toString()) } else { null } } - private fun getTotalCostPrice(holding: Holding): Amount? { + // 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!! * mapAmount(holding.averageCostPrice!!).toBigDecimal()) + Amount((holding.quantity!! * holding.averageCostPrice.toString().toDouble()).toString()) } else { null } } - private fun calculatePerformance(holding: Holding): Float? { + // visible for testing + internal fun calculatePerformance(holding: Holding): Float? { val totalBalance = getTotalBalance(holding) val totalCostPrice = getTotalCostPrice(holding) if (totalBalance != null && totalCostPrice != null) { - return ((totalBalance.toBigDecimal() - totalCostPrice.toBigDecimal()) / totalCostPrice.toBigDecimal() * 100).toFloat() + return ((totalBalance - totalCostPrice) / totalCostPrice).toFloat() * 100 } val marketValue = mapNullableAmount(holding.marketValue) val costPrice = mapNullableAmount(holding.averageCostPrice) if (marketValue != null && costPrice != null) { - return ((marketValue.toBigDecimal() - costPrice.toBigDecimal()) / costPrice.toBigDecimal() * 100).toFloat() + return ((marketValue - costPrice) / costPrice).toFloat() * 100 } return null @@ -277,11 +279,11 @@ open class FinTs4kMapper { protected open fun mapNullableMoney(amount: Money?) = amount?.let { mapMoney(it) } - protected open fun mapMoney(amount: Money) = Amount.fromString(amount.amount.string.replace(',', '.')) + 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.fromString(amount.string.replace(',', '.')) + 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 { @@ -388,7 +390,7 @@ open class FinTs4kMapper { 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 mapError(response: net.dankito.banking.client.model.response.FinTsClientResponse): Response { diff --git a/FinTs4jBankingClient/src/commonTest/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapperTest.kt b/FinTs4jBankingClient/src/commonTest/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapperTest.kt new file mode 100644 index 00000000..fc8f2594 --- /dev/null +++ b/FinTs4jBankingClient/src/commonTest/kotlin/net/codinux/banking/client/fints4k/FinTs4kMapperTest.kt @@ -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) + +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 56958e4d..4e033d05 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,3 +6,6 @@ kotlinVersion=2.0.10 kotlinxDateTimeVersion=0.5.0 jsJodaTimeZoneVersion=2.3.0 coroutinesVersion=1.8.1 + +# 0.3.10 uses Kotlin 2.0.0 +ionspinBigNumVersion=0.3.9 diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 9e7a7758..d9072388 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -556,6 +556,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" 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: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"