diff --git a/.gitignore b/.gitignore index 3e76eb8..3844fad 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ jte-classes/ .kotlin/sessions .env export-data/ +.resourceCache/ \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt index eddeda2..0d7e685 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.20.0-display-currency1 \ No newline at end of file +0.20.0-display-currency3 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9625343..a2db4b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,7 +53,7 @@ allprojects { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") // https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") // https://mvnrepository.com/artifact/it.unimi.dsi/fastutil-core implementation("it.unimi.dsi:fastutil-core:8.5.13") @@ -64,6 +64,13 @@ allprojects { // https://github.com/ajalt/clikt implementation("com.github.ajalt.clikt:clikt:4.4.0") + // https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-core + implementation("org.javamoney.moneta:moneta-core:1.4.4") + // https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-convert + implementation("org.javamoney.moneta:moneta-convert:1.4.4") + // https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-convert-ecb + implementation("org.javamoney.moneta:moneta-convert-ecb:1.4.4") + // https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3") diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/CachingMarketplaceClient.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/CachingMarketplaceClient.kt index bff825b..7f7d2fc 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/CachingMarketplaceClient.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/CachingMarketplaceClient.kt @@ -107,7 +107,6 @@ class CachingMarketplaceClient( return loadHistoricPluginData(plugin, "salesInfo", cachedSalesInfo, delegate::salesInfo) } - override suspend fun compatibleProducts(plugin: PluginId): List { return loadCached("compatibleProducts.$plugin") { delegate.compatibleProducts(plugin) diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/Marketplace.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/Marketplace.kt index 52aa7fe..6d82495 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/Marketplace.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/Marketplace.kt @@ -6,26 +6,26 @@ package dev.ja.marketplace.client import kotlinx.datetime.TimeZone +import java.math.BigDecimal +import javax.money.MonetaryAmount val MarketplaceTimeZone = TimeZone.of("Europe/Berlin") object Marketplace { private val FeeChangeTimestamp = YearMonthDay(2020, 7, 1) + private val lowFeeFactor = BigDecimal("0.05") + private val regularFeeFactor = BigDecimal("0.15") val Birthday = YearMonthDay(2019, 6, 25) - fun feeAmount(date: YearMonthDay, amount: Amount): Amount { + fun feeAmount(date: YearMonthDay, amount: MonetaryAmount): MonetaryAmount { return when { - date < FeeChangeTimestamp -> amount * 0.05.toBigDecimal() - else -> amount * 0.15.toBigDecimal() + date < FeeChangeTimestamp -> amount * lowFeeFactor + else -> amount * regularFeeFactor } } - fun paidAmount(date: YearMonthDay, amount: Amount): Amount { + fun paidAmount(date: YearMonthDay, amount: MonetaryAmount): MonetaryAmount { return amount - feeAmount(date, amount) } - - fun paidAmount(date: YearMonthDay, amount: AmountWithCurrency): AmountWithCurrency { - return amount - feeAmount(date, amount.amount) - } } \ No newline at end of file diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/YearMonthDay.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/YearMonthDay.kt index 2d38211..a8007d3 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/YearMonthDay.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/YearMonthDay.kt @@ -5,24 +5,25 @@ package dev.ja.marketplace.client -import kotlinx.datetime.* import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaZoneId import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.time.LocalDate import java.time.YearMonth +import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAdjusters import java.time.temporal.WeekFields import java.util.* import java.util.concurrent.ConcurrentHashMap @Serializable(with = YearMonthDateSerializer::class) -data class YearMonthDay( - val year: Int, - val month: Int, - val day: Int, - val timezone: TimeZone = MarketplaceTimeZone -) : Comparable { - private val instant = LocalDate(year, month, day).atStartOfDayIn(timezone) +data class YearMonthDay private constructor(private val instant: LocalDate) : Comparable { + constructor(year: Int, month: Int, day: Int) : this(LocalDate.of(year, month, day)) + + val year: Int = instant.year + val month: Int = instant.monthValue + val day: Int = instant.dayOfMonth val asIsoString: String by lazy { String.format("%04d-%02d-%02d", year, month, day) @@ -32,17 +33,13 @@ data class YearMonthDay( return YearMonthDayRange(this, end) } - fun rangeTo(end: Instant): YearMonthDayRange { - return YearMonthDayRange(this, of(end, timezone)) - } - override fun toString(): String { return asIsoString } val sortValue: Long get() { - return instant.epochSeconds + return instant.toEpochDay() } fun add(years: Int, months: Int, days: Int): YearMonthDay { @@ -50,23 +47,23 @@ data class YearMonthDay( return this } - return of( - instant.plus(years, DateTimeUnit.YEAR, timezone) - .plus(months, DateTimeUnit.MONTH, timezone) - .plus(days, DateTimeUnit.DAY, timezone) - ) + return of(instant.plusYears(years.toLong()).plusMonths(months.toLong()).plusDays(days.toLong())) } override operator fun compareTo(other: YearMonthDay): Int { return instant.compareTo(other.instant) } - fun daysUntil(date: YearMonthDay): Int { - return instant.daysUntil(date.instant, timezone) + fun daysUntil(date: YearMonthDay): Long { + return instant.until(date.instant, ChronoUnit.DAYS) + } + + fun monthsUntil(date: YearMonthDay): Long { + return instant.until(date.instant, ChronoUnit.MONTHS) } - fun monthsUntil(date: YearMonthDay): Int { - return instant.monthsUntil(date.instant, timezone) + fun toLocalDate(): LocalDate { + return instant } companion object { @@ -79,26 +76,20 @@ data class YearMonthDay( } fun now(): YearMonthDay { - return of(Clock.System.now()) + return of(LocalDate.now()) } - fun of(date: java.time.LocalDate, timezone: TimeZone = MarketplaceTimeZone): YearMonthDay { - val timezoneDate = date.atStartOfDay(timezone.toJavaZoneId()) - return YearMonthDay(timezoneDate.year, timezoneDate.monthValue, timezoneDate.dayOfMonth) - } - - fun of(date: Instant, timezone: TimeZone = MarketplaceTimeZone): YearMonthDay { + fun of(date: LocalDate): YearMonthDay { return instantCache.computeIfAbsent(date) { - val timezoneDate = date.toLocalDateTime(timezone) - YearMonthDay(timezoneDate.year, timezoneDate.monthNumber, timezoneDate.dayOfMonth) + YearMonthDay(it) } } - fun lastOfMonth(year: Int, month: Int, timezone: TimeZone = MarketplaceTimeZone): YearMonthDay { - return of(YearMonth.of(year, month).atEndOfMonth(), timezone) + fun lastOfMonth(year: Int, month: Int): YearMonthDay { + return of(YearMonth.of(year, month).atEndOfMonth()) } - private val instantCache = ConcurrentHashMap() + private val instantCache = ConcurrentHashMap() } } @@ -187,10 +178,6 @@ data class YearMonthDayRange( return YearMonthDayRange(start, end.add(years, months, days)) } - fun countDays(): Int { - return start.daysUntil(end) + 1 - } - private fun asIsoStringRange(): String { return "${start.asIsoString} - ${end.asIsoString}" } @@ -198,7 +185,7 @@ data class YearMonthDayRange( companion object { fun currentWeek(timezone: TimeZone = MarketplaceTimeZone): YearMonthDayRange { val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek - val firstOfWeek = java.time.LocalDate.now(timezone.toJavaZoneId()).with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + val firstOfWeek = LocalDate.now(timezone.toJavaZoneId()).with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) val lastOfWeek = firstOfWeek.plusDays(6) return YearMonthDayRange(YearMonthDay.of(firstOfWeek), YearMonthDay.of(lastOfWeek)) } diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/moneyUtils.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/moneyUtils.kt new file mode 100644 index 0000000..aee1836 --- /dev/null +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/moneyUtils.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.client + +import org.javamoney.moneta.FastMoney +import java.math.BigDecimal +import java.math.RoundingMode +import javax.money.MonetaryAmount + +operator fun MonetaryAmount.plus(other: MonetaryAmount): MonetaryAmount { + return this.add(other) +} + +operator fun MonetaryAmount.minus(other: MonetaryAmount): MonetaryAmount { + return this.subtract(other) +} + +operator fun MonetaryAmount.times(other: Number): MonetaryAmount { + require(this is FastMoney) + val result = (other as? BigDecimal ?: BigDecimal(other.toString())) + .multiply(this.number.numberValue(BigDecimal::class.java)) + .setScale(this.scale, RoundingMode.HALF_UP) + return FastMoney.of(result, this.currency) +} \ No newline at end of file diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/serializers.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/serializers.kt index 38d197c..f20b742 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/serializers.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/serializers.kt @@ -5,20 +5,23 @@ package dev.ja.marketplace.client -import dev.ja.marketplace.services.Currency import kotlinx.datetime.Instant import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.IntArraySerializer +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.nullable import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.CompositeDecoder import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure import kotlinx.serialization.json.* +import org.javamoney.moneta.FastMoney +import org.javamoney.moneta.Money import java.math.BigDecimal +import javax.money.MonetaryAmount @OptIn(ExperimentalSerializationApi::class) object YearMonthDateSerializer : KSerializer { @@ -48,28 +51,28 @@ object YearMonthDateSerializer : KSerializer { ) } -object AmountSerializer : KSerializer { - override fun deserialize(decoder: Decoder): Amount { - return decoder.decodeString().toBigDecimal() +object BigDecimalSerializer : KSerializer { + override fun deserialize(decoder: Decoder): BigDecimal { + return decoder.decodeDouble().toBigDecimal() } - override fun serialize(encoder: Encoder, value: Amount) { - encoder.encodeString(value.toPlainString()) + override fun serialize(encoder: Encoder, value: BigDecimal) { + encoder.encodeDouble(value.toDouble()) } - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Amount", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("amount", PrimitiveKind.DOUBLE) } -object CurrencySerializer : KSerializer { - override fun deserialize(decoder: Decoder): Currency { - return MarketplaceCurrencies.of(decoder.decodeString()) +object MonetaryAmountUsdSerializer : KSerializer { + override fun deserialize(decoder: Decoder): MonetaryAmount { + return FastMoney.of(decoder.decodeDouble(), "USD") } - override fun serialize(encoder: Encoder, value: Currency) { - encoder.encodeString(value.isoCode) + override fun serialize(encoder: Encoder, value: MonetaryAmount) { + encoder.encodeDouble(value.number.doubleValueExact()) } - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Currency", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("amount", PrimitiveKind.DOUBLE) } object CDateSerializer : KSerializer { @@ -96,3 +99,75 @@ class NullableStringSerializer : KSerializer { return String.serializer().nullable.serialize(encoder, value) } } + + +object PluginSaleSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("pluginSale") { + element("ref") + element("date") + element("amount") + element("currency") + element("amountUSD") + element("period") + element("customer") + element("reseller") + element>("lineItems") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): PluginSale { + return decoder.decodeStructure(descriptor) { + var ref: String? = null + var date: YearMonthDay? = null + var amountValue: Double? = null + var currency: String? = null + var amountValueUSD: Double? = null + var period: LicensePeriod? = null + var customer: CustomerInfo? = null + var reseller: ResellerInfo? = null + var lineItems: List? = null + + while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> ref = decodeStringElement(descriptor, index) + 1 -> date = decodeSerializableElement(descriptor, index, YearMonthDay.serializer()) + 2 -> amountValue = decodeDoubleElement(descriptor, index) + 3 -> currency = decodeStringElement(descriptor, index) + 4 -> amountValueUSD = decodeDoubleElement(descriptor, index) + 5 -> period = decodeSerializableElement(descriptor, index, LicensePeriod.serializer()) + 6 -> customer = decodeNullableSerializableElement(descriptor, index, CustomerInfo.serializer()) + 7 -> reseller = decodeNullableSerializableElement(descriptor, index, ResellerInfo.serializer()) + 8 -> lineItems = decodeNullableSerializableElement(descriptor, index, ListSerializer(JsonPluginSaleItem.serializer())) + CompositeDecoder.DECODE_DONE -> break + else -> error("Unexpected index: $index") + } + } + require(ref != null && date != null && amountValue != null && amountValueUSD != null && period != null && customer != null) + require(lineItems != null) + + PluginSale( + ref, + date, + FastMoney.of(amountValue, currency), + FastMoney.of(amountValueUSD, "USD"), + period, + customer, + reseller, + lineItems.map { + PluginSaleItem( + it.type, + it.licenseIds, + it.subscriptionDates, + FastMoney.of(it.amount, currency), + FastMoney.of(it.amountUSD, "USD"), + it.discountDescriptions + ) + } + ) + } + } + + override fun serialize(encoder: Encoder, value: PluginSale) { + TODO("not yet implemented") + } +} \ No newline at end of file diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/types.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/types.kt index d1d9b46..5f35dfb 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/types.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/client/types.kt @@ -6,11 +6,13 @@ package dev.ja.marketplace.client import dev.ja.marketplace.services.Country -import dev.ja.marketplace.services.Currency import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.math.BigDecimal +import javax.money.CurrencyUnit +import javax.money.Monetary +import javax.money.MonetaryAmount typealias UserId = String typealias PluginId = Int @@ -25,48 +27,9 @@ typealias LicenseId = String typealias JetBrainsProductId = String typealias PluginModuleName = String -typealias Amount = BigDecimal - -data class AmountWithCurrency(val amount: Amount, val currencyCode: String) : Comparable { - constructor(amount: Amount, currency: Currency) : this(amount, currency.isoCode) - - override fun compareTo(other: AmountWithCurrency): Int { - return when (this.currencyCode) { - other.currencyCode -> this.amount.compareTo(other.amount) - else -> this.currencyCode.compareTo(other.currencyCode) - } - } - - operator fun times(other: BigDecimal): AmountWithCurrency { - return AmountWithCurrency(amount.times(other), currencyCode) - } - - operator fun minus(other: BigDecimal): AmountWithCurrency { - return AmountWithCurrency(amount.minus(other), currencyCode) - } - - operator fun plus(other: BigDecimal): AmountWithCurrency { - return AmountWithCurrency(amount.plus(other), currencyCode) - } - - operator fun minus(other: AmountWithCurrency): AmountWithCurrency { - assert(this.currencyCode == other.currencyCode) - return AmountWithCurrency(amount.minus(other.amount), currencyCode) - } -} - -fun Amount.withCurrency(currency: String): AmountWithCurrency { - return AmountWithCurrency(this, currency) -} - -fun Amount.withCurrency(currency: Currency): AmountWithCurrency { - return AmountWithCurrency(this, currency.isoCode) -} - interface WithAmounts { - val amount: Amount - val currency: Currency - val amountUSD: Amount + val amount: MonetaryAmount + val amountUSD: MonetaryAmount } @Serializable @@ -354,51 +317,37 @@ data class PluginRating( } } -@Serializable +@Serializable(PluginSaleSerializer::class) data class PluginSale( - @SerialName("ref") val ref: String, - @SerialName("date") val date: YearMonthDay, - @SerialName("amount") - @Serializable(with = AmountSerializer::class) - override val amount: Amount, - @SerialName("amountUSD") - @Serializable(with = AmountSerializer::class) - override val amountUSD: Amount, - @SerialName("currency") - @Serializable(CurrencySerializer::class) - override val currency: Currency, - @SerialName("period") + override val amount: MonetaryAmount, + override val amountUSD: MonetaryAmount, val licensePeriod: LicensePeriod, - @SerialName("customer") val customer: CustomerInfo, - @SerialName("reseller") val reseller: ResellerInfo? = null, - @SerialName("lineItems") val lineItems: List ) : Comparable, WithAmounts { - override fun compareTo(other: PluginSale): Int { return date.compareTo(other.date) } } -object MarketplaceCurrencies : Iterable { - val USD = Currency("USD", "US $", true) - val EUR = Currency("EUR", "Є", true) - val JPY = Currency("JPY", "JPY", false) - val GBP = Currency("GBP", "£", true) - val CZK = Currency("CZK", "Kč", false) - val CNY = Currency("CNY", "CNY", false) +object MarketplaceCurrencies : Iterable { + val USD = Monetary.getCurrency("USD") + val EUR = Monetary.getCurrency("EUR") + val JPY = Monetary.getCurrency("JPY") + val GBP = Monetary.getCurrency("GBP") + val CZK = Monetary.getCurrency("CZK") + val CNY = Monetary.getCurrency("CNY") private val allCurrencies = listOf(USD, EUR, JPY, GBP, CZK, CNY) - override fun iterator(): Iterator { + override fun iterator(): Iterator { return allCurrencies.iterator() } - fun of(id: String): Currency { + fun of(id: String): CurrencyUnit { return when (id) { "USD" -> USD "EUR" -> EUR @@ -474,7 +423,7 @@ enum class ResellerType(val displayString: String) { } @Serializable -data class PluginSaleItem( +internal data class JsonPluginSaleItem( @SerialName("type") val type: PluginSaleItemType, @SerialName("licenseIds") @@ -482,13 +431,20 @@ data class PluginSaleItem( @SerialName("subscriptionDates") val subscriptionDates: YearMonthDayRange, @SerialName("amount") - @Serializable(with = AmountSerializer::class) - val amount: Amount, + val amount: Double, @SerialName("amountUsd") - @Serializable(with = AmountSerializer::class) - val amountUSD: Amount, + val amountUSD: Double, @SerialName("discountDescriptions") val discountDescriptions: List +) + +data class PluginSaleItem( + val type: PluginSaleItemType, + val licenseIds: List, + val subscriptionDates: YearMonthDayRange, + val amount: MonetaryAmount, + val amountUSD: MonetaryAmount, + val discountDescriptions: List ) { val isFreeLicense: Boolean = discountDescriptions.any { it.percent == 100.0 } } @@ -554,11 +510,11 @@ data class MarketplacePluginInfo( @SerialName("periods") val licensePeriod: List, @SerialName("individualPrice") - @Serializable(with = AmountSerializer::class) - val individualPrice: Amount, + @Serializable(with = MonetaryAmountUsdSerializer::class) + val individualPrice: MonetaryAmount, @SerialName("businessPrice") - @Serializable(with = AmountSerializer::class) - val businessPrice: Amount, + @Serializable(with = MonetaryAmountUsdSerializer::class) + val businessPrice: MonetaryAmount, @SerialName("licensing") val licensingType: LicensingType, @SerialName("status") @@ -574,19 +530,7 @@ data class MarketplacePluginInfo( // only available with fullInfo=true @SerialName("versions") val majorVersions: List? = null, -) { - fun subscriptionPrice(customerType: CustomerType, subscriptionType: LicensePeriod): Amount { - val basePrice = when (customerType) { - CustomerType.Organization -> this.businessPrice - CustomerType.Personal -> this.individualPrice - } - val factor = when (subscriptionType) { - LicensePeriod.Annual -> 10.0 - LicensePeriod.Monthly -> 1.0 - } - return basePrice * factor.toBigDecimal() - } -} +) enum class DownloadCountType(val requestPathSegment: String) { Downloads("downloads-count"), @@ -950,11 +894,11 @@ data class PriceInfoTypeData( @Serializable data class PriceInfoData( @SerialName("price") - @Serializable(AmountSerializer::class) - val price: Amount, + @Serializable(BigDecimalSerializer::class) + val price: BigDecimal, @SerialName("priceTaxed") - @Serializable(AmountSerializer::class) - val priceTaxed: Amount? = null, + @Serializable(BigDecimalSerializer::class) + val priceTaxed: BigDecimal? = null, @SerialName("newShopCode") val newShopCode: String, ) diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRateProvider.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRateProvider.kt deleted file mode 100644 index a3e2d0d..0000000 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRateProvider.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.exchangeRate - -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange -import it.unimi.dsi.fastutil.objects.Object2DoubleMap - -/** - * Provides current or historical currency exchange rates. - */ -interface ExchangeRateProvider { - /** - * @param dates `null` for the current exchange rate or a date range for historical data. - * @param fromIsoCode ISO code of the base currency - * @param toIsoCodes ISO codes of the target currencies - * @return Map of target currency ISO code to the exchange rate factor - */ - suspend fun fetchExchangeRates( - dates: YearMonthDayRange, - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer? = null, - ): Iterable - - suspend fun fetchLatestExchangeRates( - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer? = null, - ): ExchangeRateSet -} - -typealias DoubleTransformer = (Double) -> Double - -data class ExchangeRateSet( - val date: YearMonthDay, - // currency -> exchange rate - val exchangeRates: Object2DoubleMap, -) : Comparable { - override fun compareTo(other: ExchangeRateSet): Int { - return date.compareTo(other.date) - } -} \ No newline at end of file diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRates.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRates.kt index 33ca93c..5b3a272 100644 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRates.kt +++ b/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRates.kt @@ -5,97 +5,71 @@ package dev.ja.marketplace.exchangeRate -import dev.ja.marketplace.client.Amount import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange -import dev.ja.marketplace.services.Currency -import it.unimi.dsi.fastutil.objects.Object2DoubleMap +import org.javamoney.moneta.FastMoney +import org.javamoney.moneta.convert.ecb.ECBCurrentRateProvider +import org.javamoney.moneta.convert.ecb.ECBHistoricRateProvider +import org.javamoney.moneta.spi.CompoundRateProvider import java.math.BigDecimal -import java.util.concurrent.ConcurrentSkipListMap -import java.util.concurrent.atomic.AtomicReference - -val EmptyExchangeRates = ExchangeRates(IdentityExchangeRateProvider, YearMonthDay.now(), "USD", emptyList()) +import java.math.MathContext +import java.math.RoundingMode +import java.time.LocalDate +import java.util.* +import javax.money.CurrencyUnit +import javax.money.Monetary +import javax.money.MonetaryAmount +import javax.money.convert.* /** * Prefetched exchange rates. */ -class ExchangeRates( - private val exchangeRateProvider: ExchangeRateProvider, - private val firstValidDate: YearMonthDay, - val targetCurrencyCode: String, - supportedSourceCurrencies: Iterable, -) { - private val sourceCurrencyCodes = supportedSourceCurrencies.map(Currency::isoCode) - targetCurrencyCode - private val latestFetchedDate = AtomicReference(YearMonthDay.MIN) - private val exchangeRateInverter: DoubleTransformer = { 1.0 / it } +class ExchangeRates(targetCurrencyCode: String) { + val targetCurrency: CurrencyUnit = Monetary.getCurrency(targetCurrencyCode) - // key must only be the date, because the map uses compareTo for equality - private val cachedRates = ConcurrentSkipListMap>() - private val latestExchangeRate = AtomicReference(null) + private val historicRateProvider: ExchangeRateProvider = ECBHistoricRateProvider() + private val currentRateProvider: ExchangeRateProvider = ECBCurrentRateProvider() + private val composedRateProvider = CompoundRateProvider(listOf(historicRateProvider, currentRateProvider)) - suspend fun convert(date: YearMonthDay, amount: Amount, sourceCurrency: String): Amount { - return when { - sourceCurrency == targetCurrencyCode -> amount - else -> amount * BigDecimal.valueOf(getCurrencyConversionFactor(date, sourceCurrency)) - } + fun convert(date: YearMonthDay, amount: MonetaryAmount): MonetaryAmount { + val now = YearMonthDay.now() + val fixedDate = if (date > now) now else date + val query = ConversionQueryBuilder + .of() + .setTermCurrency(targetCurrency) + .set(Array::class.java, createLookupDates(fixedDate)) + .build() + val conversion = composedRateProvider.getCurrencyConversion(query) + return conversion.applyConversion(amount, targetCurrency) } - private suspend fun getCurrencyConversionFactor(date: YearMonthDay, sourceCurrency: String): Double { - if (sourceCurrency == targetCurrencyCode) { - return 1.0 + // fixed apply method to make it work with FastMoney + private fun CurrencyConversion.applyConversion(amount: MonetaryAmount, termCurrency: CurrencyUnit): MonetaryAmount { + if (termCurrency == amount.currency) { + return amount } - val currentStableRateDate = YearMonthDay.now().add(0, 0, -1) - - // handling of latest, unstable rate - if (date > currentStableRateDate) { - var cachedLatest = latestExchangeRate.get() - if (cachedLatest == null || cachedLatest.date != date) { - // fetch and cache the latest exchange rate - val latestExchangeRates = exchangeRateProvider.fetchLatestExchangeRates( - targetCurrencyCode, - sourceCurrencyCodes, - exchangeRateInverter - ) - - cachedLatest = latestExchangeRates.copy(date = date) - this.latestExchangeRate.set(cachedLatest) - } - - val value = cachedLatest.exchangeRates.getOrDefault(sourceCurrency as Any, -1.0) - return when { - value >= 0.0 -> value - else -> throw IllegalStateException("Latest exchange rate unavailable for $date, $sourceCurrency") - } - } + val rate: ExchangeRate = getExchangeRate(amount) + if (Objects.isNull(rate) || amount.currency != rate.baseCurrency) { + throw CurrencyConversionException( + amount.currency, + termCurrency, null + ) - // handling of stable rates - if (date > latestFetchedDate.get()) { - cacheAll(firstValidDate.rangeTo(currentStableRateDate)) - latestFetchedDate.set(currentStableRateDate) - } - - // day's rate. For missing dates (e.g. weekend) try the previous rate and then the next available date as fallback - val bestEntry = cachedRates.floorEntry(date) ?: cachedRates.ceilingEntry(date) - val value = bestEntry?.value?.getOrDefault(sourceCurrency as Any, -1.0) - return when { - value != null && value >= 0.0 -> value - else -> throw IllegalStateException("No cached exchange rate available for $date") } + val multiplied = rate.factor.numberValue(BigDecimal::class.java) + .multiply(amount.number.numberValue(BigDecimal::class.java)) + .setScale(5, RoundingMode.HALF_UP) + return FastMoney.of(multiplied, rate.currency) +// return FastMoney.of(MoneyUtils.getBigDecimal(multiplied), rate.currency)//.with(MonetaryOperators.rounding(5)) +// return amount.factory.setCurrency(rate.currency).setNumber(multiplied).create().with(MonetaryOperators.rounding(5)) } - // Because we have multiple source currencies and only a single target currency we're requesting the reverse exchange rate. - // The API only supports one base currency and [1,n] target currencies. - private suspend fun cacheAll(dateRange: YearMonthDayRange) { - val exchangeRates = exchangeRateProvider.fetchExchangeRates( - dateRange, - targetCurrencyCode, - sourceCurrencyCodes, - exchangeRateInverter - ) - - for (result in exchangeRates) { - cachedRates[result.date] = result.exchangeRates - } - } + // requested date with several fallback to make up to weekends + private fun createLookupDates(date: YearMonthDay) = arrayOf( + date.toLocalDate(), + date.add(0, 0, -1).toLocalDate(), + date.add(0, 0, -2).toLocalDate(), + date.add(0, 0, 1).toLocalDate(), + date.add(0, 0, 2).toLocalDate(), + ) } \ No newline at end of file diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/FrankfurterExchangeRateProvider.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/FrankfurterExchangeRateProvider.kt deleted file mode 100644 index fb4a032..0000000 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/FrankfurterExchangeRateProvider.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.exchangeRate - -import dev.ja.marketplace.client.ClientLogLevel -import dev.ja.marketplace.client.KtorHttpClientFactory -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange -import io.ktor.client.call.* -import io.ktor.client.request.* -import it.unimi.dsi.fastutil.objects.Object2DoubleArrayMap -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set - -/** - * Implementation using open-source project https://github.com/hakanensari/frankfurter. - */ -class FrankfurterExchangeRateProvider(apiUrl: String, logLevel: ClientLogLevel) : ExchangeRateProvider { - private val httpClient = KtorHttpClientFactory.createHttpClientByUrl(apiUrl, logLevel = logLevel) - - override suspend fun fetchExchangeRates( - dates: YearMonthDayRange, - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer?, - ): Iterable { - return requestDateRangeRates(dates, fromIsoCode, toIsoCodes, exchangeRateTransformer) - } - - override suspend fun fetchLatestExchangeRates( - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer?, - ): ExchangeRateSet { - return fetchSingleDay(null, fromIsoCode, toIsoCodes, exchangeRateTransformer) - } - - private suspend fun requestDateRangeRates( - dates: YearMonthDayRange, - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer?, - ): Iterable { - // The API returns daily rates for ranges up to 90 days. - // For larger ranges, we're request for chunks of 90 days. - val allResults = sortedMapOf() - - dates.days().chunked(90).forEach { days -> - val first = days.first() - val last = days.last() - when { - first == last -> fetchSingleDay(days.first(), fromIsoCode, toIsoCodes, allResults, exchangeRateTransformer) - else -> fetchDateRange( - "${first.asIsoString}..${last.asIsoString}", - fromIsoCode, - toIsoCodes, - allResults, - exchangeRateTransformer - ) - } - } - - return allResults.values.toList() - } - - private suspend fun fetchDateRange( - path: String, - fromIsoCode: String, - toIsoCodes: Iterable, - allResults: MutableMap, - exchangeRateTransformer: DoubleTransformer? - ) { - val response = httpClient.get(path) { - parameter("from", fromIsoCode) - parameter("to", toIsoCodes.joinToString(",")) - }.body() - - // dates to currency/rate pairs - response.dailyRates.forEach { (dateString, currentRateMap) -> - val date = YearMonthDay.parse(dateString) - val transformedRates = when (exchangeRateTransformer) { - null -> currentRateMap - else -> currentRateMap.mapValues { exchangeRateTransformer(it.value) } - } - allResults[date] = ExchangeRateSet(date, Object2DoubleArrayMap(transformedRates)) - } - } - - private suspend fun fetchSingleDay( - date: YearMonthDay, - fromIsoCode: String, - toIsoCodes: Iterable, - target: MutableMap, - exchangeRateTransformer: DoubleTransformer? - ) { - val result = fetchSingleDay(date, fromIsoCode, toIsoCodes, exchangeRateTransformer) - target[result.date] = result - } - - private suspend fun fetchSingleDay( - date: YearMonthDay?, - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer?, - ): ExchangeRateSet { - val response = httpClient.get(if (date == null) "/latest" else "/${date.asIsoString}") { - parameter("from", fromIsoCode) - parameter("to", toIsoCodes.joinToString(",")) - }.body() - - val transformedRates = when (exchangeRateTransformer) { - null -> response.rates - else -> response.rates.mapValues { exchangeRateTransformer(it.value) } - } - - return ExchangeRateSet(YearMonthDay.parse(response.date), Object2DoubleArrayMap(transformedRates)) - } -} - -@Serializable -private data class ExchangeDateResponse( - @SerialName("amount") - val amount: Double, - @SerialName("base") - val base: String, - @SerialName("date") - val date: String, - @SerialName("rates") - val rates: Map -) - -@Serializable -private data class ExchangeDateRangeRateResponse( - @SerialName("amount") - val amount: Double, - @SerialName("base") - val base: String, - @SerialName("start_date") - val startDate: String, - @SerialName("end_date") - val endDate: String, - @SerialName("rates") - val dailyRates: Map> -) diff --git a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/IdentityExchangeRateProvider.kt b/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/IdentityExchangeRateProvider.kt deleted file mode 100644 index 0209610..0000000 --- a/marketplace-client/src/main/kotlin/dev/ja/marketplace/exchangeRate/IdentityExchangeRateProvider.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.exchangeRate - -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange -import it.unimi.dsi.fastutil.objects.Object2DoubleMaps - -object IdentityExchangeRateProvider : ExchangeRateProvider { - override suspend fun fetchExchangeRates( - dates: YearMonthDayRange, - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer? - ): Iterable { - return emptyList() - } - - override suspend fun fetchLatestExchangeRates( - fromIsoCode: String, - toIsoCodes: Iterable, - exchangeRateTransformer: DoubleTransformer? - ): ExchangeRateSet { - return ExchangeRateSet(YearMonthDay.now(), Object2DoubleMaps.emptyMap()) - } -} \ No newline at end of file diff --git a/marketplace-client/src/test/kotlin/dev/ja/marketplace/client/PluginSaleTest.kt b/marketplace-client/src/test/kotlin/dev/ja/marketplace/client/PluginSaleTest.kt new file mode 100644 index 0000000..9b5269f --- /dev/null +++ b/marketplace-client/src/test/kotlin/dev/ja/marketplace/client/PluginSaleTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.client + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import javax.money.Monetary + +class PluginSaleTest { + @Test + fun deserializer() { + val json = """ + { + "ref": "my-ref", + "date": [ + 2024, + 6, + 12 + ], + "amount": 3.90, + "amountUSD": 4.19, + "currency": "EUR", + "period": "Monthly", + "customer": { + "code": 42, + "name": "", + "country": "Germany", + "type": "Personal" + }, + "reseller": null, + "lineItems": [ + { + "type": "RENEW", + "licenseIds": [ + "abcdef" + ], + "subscriptionDates": { + "start": [ + 2024, + 6, + 12 + ], + "end": [ + 2024, + 7, + 11 + ] + }, + "amount": 3.90, + "amountUsd": 4.19, + "discountDescriptions": [] + } + ] + } + """.trimIndent() + + val sale = Json.decodeFromString(json) + assertEquals("EUR 3.9", sale.lineItems[0].amount.toString()) + } +} \ No newline at end of file diff --git a/marketplace-client/src/test/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRatesTest.kt b/marketplace-client/src/test/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRatesTest.kt new file mode 100644 index 0000000..e0c8660 --- /dev/null +++ b/marketplace-client/src/test/kotlin/dev/ja/marketplace/exchangeRate/ExchangeRatesTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.exchangeRate + +import dev.ja.marketplace.client.YearMonthDay +import org.javamoney.moneta.FastMoney +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ExchangeRatesTest { + @Test + fun historicDate() { + val exchangeRates = ExchangeRates("USD") + val amount = FastMoney.of(10, "EUR") + assertEquals(FastMoney.of(10.963, "USD"), exchangeRates.convert(YearMonthDay(2020, 4, 13), amount)) + assertEquals(FastMoney.of(10.652, "USD"), exchangeRates.convert(YearMonthDay(2024, 4, 13), amount)) + } + + @Test + fun futureDate() { + val exchangeRates = ExchangeRates("USD") + val amount = FastMoney.of(10, "EUR") + val expected = exchangeRates.convert(YearMonthDay.now(), amount) + + assertEquals(expected, exchangeRates.convert(YearMonthDay.now().add(0, 0, 1), amount)) + assertEquals(expected, exchangeRates.convert(YearMonthDay.now().add(10, 10, 10), amount)) + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/ChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/ChurnProcessor.kt index 9750d75..da0c04b 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/ChurnProcessor.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/ChurnProcessor.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Joachim Ansorg. + * Copyright (c) 2023-2024 Joachim Ansorg. * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -8,11 +8,10 @@ package dev.ja.marketplace.churn import dev.ja.marketplace.client.LicensePeriod import dev.ja.marketplace.client.YearMonthDayRange -interface ChurnProcessor { +interface ChurnProcessor { fun init() fun processValue( - id: ID, value: T, validity: YearMonthDayRange, isAcceptedValue: Boolean, diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/CustomerChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/CustomerChurnProcessor.kt new file mode 100644 index 0000000..c282a11 --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/CustomerChurnProcessor.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.churn + +import dev.ja.marketplace.client.CustomerInfo +import dev.ja.marketplace.client.YearMonthDay + +class CustomerChurnProcessor( + previouslyActiveMarkerDate: YearMonthDay, + currentlyActiveMarkerDate: YearMonthDay +) : MarketplaceIntChurnProcessor(previouslyActiveMarkerDate, currentlyActiveMarkerDate) { + override fun getId(value: CustomerInfo): Int { + return value.code + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/LicenseChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/LicenseChurnProcessor.kt new file mode 100644 index 0000000..8418228 --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/LicenseChurnProcessor.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.churn + +import dev.ja.marketplace.client.YearMonthDay +import dev.ja.marketplace.data.LicenseInfo + +class LicenseChurnProcessor( + previouslyActiveMarkerDate: YearMonthDay, + currentlyActiveMarkerDate: YearMonthDay +) : MarketplaceStringChurnProcessor(previouslyActiveMarkerDate, currentlyActiveMarkerDate) { + override fun getId(value: LicenseInfo): String { + return value.id + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceChurnProcessor.kt index 983919d..b747be9 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceChurnProcessor.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceChurnProcessor.kt @@ -15,64 +15,65 @@ import dev.ja.marketplace.client.YearMonthDayRange * Churn processor, which uses the license type (new / renewal) to decide if a user churned or not. * Users with renewals even after the end of the range (e.g. after end of month) are considered as active and not as churned. * We're not using any kind of grace period because it's implicitly used if the license type is "renewal". + * + * This class is abstract top optimize performance. Subclasses define non-generic getId() methods to avoid boxing Int IDs to objects. */ -class MarketplaceChurnProcessor( +abstract class MarketplaceChurnProcessor( private val previouslyActiveMarkerDate: YearMonthDay, private val currentlyActiveMarkerDate: YearMonthDay, - private val hashSetFactory: () -> MutableCollection, -) : ChurnProcessor { +) : ChurnProcessor { override fun init() {} - private val previousPeriodItems: MutableCollection = hashSetFactory() - private val activeItems: MutableCollection = hashSetFactory() - private val activeItemsUnaccepted: MutableCollection = hashSetFactory() + protected abstract fun previousPeriodItemCount(): Int + + protected abstract fun activeItemsCount(): Int + + protected abstract fun addPreviousPeriodItem(value: T) + + protected abstract fun addActiveItem(value: T) + + protected abstract fun addActiveUnacceptedItem(value: T) + + protected abstract fun churnedItemsCount(): Int override fun processValue( - id: ID, value: T, validity: YearMonthDayRange, isAcceptedValue: Boolean, isExplicitRenewal: Boolean ) { if (isAcceptedValue && previouslyActiveMarkerDate in validity) { - previousPeriodItems.add(id) + addPreviousPeriodItem(value) } if (currentlyActiveMarkerDate in validity || isExplicitRenewal && validity.end > currentlyActiveMarkerDate) { when { - isAcceptedValue -> activeItems.add(id) - else -> activeItemsUnaccepted.add(id) + isAcceptedValue -> addActiveItem(value) + else -> addActiveUnacceptedItem(value) } } } + override fun getResult(period: LicensePeriod): ChurnResult { - val activeAtStart = previousPeriodItems.size + val activeAtStart = previousPeriodItemCount() // For example, users which were licensed end of last month, but no longer are licensed end of this month. // We're not counting users, which switched the license type, e.g. from "monthly" to "annual" - val churned = churnedIds() + val churnedCount = churnedItemsCount() val churnRate = when (activeAtStart) { 0 -> 0.0 - else -> churned.size.toDouble() / activeAtStart.toDouble() + else -> churnedCount.toDouble() / activeAtStart.toDouble() } return ChurnResult( churnRate, activeAtStart, - activeItems.size, - churned.size, + activeItemsCount(), + churnedCount, previouslyActiveMarkerDate, currentlyActiveMarkerDate, period ) } - - fun churnedIds(): Set { - val churned = hashSetFactory().toMutableSet() - churned.addAll(previousPeriodItems) - churned.removeAll(activeItems) - churned.removeAll(activeItemsUnaccepted) - return churned - } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceIntChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceIntChurnProcessor.kt new file mode 100644 index 0000000..18aa3f1 --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceIntChurnProcessor.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.churn + +import dev.ja.marketplace.client.YearMonthDay +import it.unimi.dsi.fastutil.ints.IntArraySet + +abstract class MarketplaceIntChurnProcessor( + previouslyActiveMarkerDate: YearMonthDay, + currentlyActiveMarkerDate: YearMonthDay +) : MarketplaceChurnProcessor(previouslyActiveMarkerDate, currentlyActiveMarkerDate) { + private val previousPeriodItems = IntArraySet(250) + private val activeItems = IntArraySet(250) + private val activeUnacceptedItems = IntArraySet(250) + + protected abstract fun getId(value: T): Int + + override fun previousPeriodItemCount(): Int { + return previousPeriodItems.size + } + + override fun activeItemsCount(): Int { + return activeItems.size + } + + override fun addPreviousPeriodItem(value: T) { + previousPeriodItems += getId(value) + } + + override fun addActiveItem(value: T) { + activeItems += getId(value) + } + + override fun addActiveUnacceptedItem(value: T) { + activeUnacceptedItems += getId(value) + } + + override fun churnedItemsCount(): Int { + val churned = churnedIds() + return churned.size + } + + fun churnedIds(): Set { + val churned = IntArraySet(previousPeriodItems) + churned.removeAll(activeItems) + churned.removeAll(activeUnacceptedItems) + return churned + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceStringChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceStringChurnProcessor.kt new file mode 100644 index 0000000..d7aff6f --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/MarketplaceStringChurnProcessor.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.churn + +import dev.ja.marketplace.client.YearMonthDay + +abstract class MarketplaceStringChurnProcessor( + previouslyActiveMarkerDate: YearMonthDay, + currentlyActiveMarkerDate: YearMonthDay +) : MarketplaceChurnProcessor(previouslyActiveMarkerDate, currentlyActiveMarkerDate) { + private val previousPeriodItems = mutableSetOf() + private val activeItems = mutableSetOf() + private val activeUnacceptedItems = mutableSetOf() + + protected abstract fun getId(value: T): String + + override fun previousPeriodItemCount(): Int { + return previousPeriodItems.size + } + + override fun activeItemsCount(): Int { + return activeItems.size + } + + override fun addPreviousPeriodItem(value: T) { + previousPeriodItems += getId(value) + } + + override fun addActiveItem(value: T) { + activeItems += getId(value) + } + + override fun addActiveUnacceptedItem(value: T) { + activeUnacceptedItems += getId(value) + } + + override fun churnedItemsCount(): Int { + return churnedIds().size + } + + fun churnedIds(): Set { + val churned = HashSet(previousPeriodItems) + churned.removeAll(activeItems) + churned.removeAll(activeUnacceptedItems) + return churned + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessor.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessor.kt deleted file mode 100644 index 2e84612..0000000 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessor.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2023 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.churn - -import dev.ja.marketplace.client.LicensePeriod -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange - -/** - * Churn rate is calculated as "number of users lost in the date range / users at beginning of the date range". - * - * This implementation is problematic, because - * - the grace period is not necessarily the same as the one used by JetBrains Marketplace - */ -class SimpleChurnProcessor( - private val previouslyActiveMarkerDate: YearMonthDay, - private val currentlyActiveMarkerDate: YearMonthDay, - graceTimeDays: Int, -) : ChurnProcessor { - private val previouslyActiveMarkerDateWithGraceTime = previouslyActiveMarkerDate.add(0, 0, graceTimeDays) - private val currentlyActiveMarkerDateWithGraceTime = currentlyActiveMarkerDate.add(0, 0, graceTimeDays) - - private val previousPeriodItems = mutableSetOf() - private val activeItems = mutableSetOf() - private val activeItemsUnaccepted = mutableSetOf() - - override fun init() {} - - override fun processValue( - id: Int, - value: T, - validity: YearMonthDayRange, - isAcceptedValue: Boolean, - isExplicitRenewal: Boolean - ) { - if (isAcceptedValue && previouslyActiveMarkerDate in validity) { - previousPeriodItems += id - } - - // valid before end, valid until end or later - if (isValid(validity, currentlyActiveMarkerDate, currentlyActiveMarkerDateWithGraceTime)) { - if (isAcceptedValue) { - activeItems += id - } else { - activeItemsUnaccepted += id - } - } - } - - override fun getResult(period: LicensePeriod): ChurnResult { - val activeAtStart = previousPeriodItems.size - val churned = previousPeriodItems.count { it !in activeItems && it !in activeItemsUnaccepted } - val churnRate = when (activeAtStart) { - 0 -> 0.0 - else -> churned.toDouble() / activeAtStart.toDouble() - } - - return ChurnResult( - churnRate, - activeAtStart, - activeItems.size, - churned, - previouslyActiveMarkerDate, - currentlyActiveMarkerDate, - period - ) - } - - private fun isValid( - validity: YearMonthDayRange, - markerDate: YearMonthDay, - markerDateWithGraceTime: YearMonthDay - ): Boolean { - return markerDate in validity || markerDateWithGraceTime in validity - } -} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/Amounts.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/Amounts.kt deleted file mode 100644 index 2b6c92c..0000000 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/Amounts.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2023-2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.data - -import dev.ja.marketplace.client.Amount -import dev.ja.marketplace.client.WithAmounts -import dev.ja.marketplace.services.Currency -import java.math.BigDecimal - -data class Amounts( - override val amount: Amount, - override val currency: Currency, - override val amountUSD: Amount -) : WithAmounts { - companion object { - fun zero(currency: Currency) = Amounts(BigDecimal.ZERO, currency, BigDecimal.ZERO) - } -} diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/DataTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/DataTable.kt index b56bc5c..6f93883 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/DataTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/DataTable.kt @@ -5,10 +5,13 @@ package dev.ja.marketplace.data -import dev.ja.marketplace.client.* +import dev.ja.marketplace.client.Marketplace +import dev.ja.marketplace.client.MarketplaceCurrencies +import dev.ja.marketplace.client.WithAmounts +import dev.ja.marketplace.client.YearMonthDay import dev.ja.marketplace.exchangeRate.ExchangeRates -import dev.ja.marketplace.services.Currency import java.util.concurrent.atomic.AtomicReference +import javax.money.MonetaryAmount enum class AriaSortOrder(val attributeValue: String) { Ascending("ascending"), @@ -85,26 +88,19 @@ abstract class SimpleDataTable( return RenderedDataTable(this, cachedSections.get()) } - protected suspend fun Amount.render(date: YearMonthDay, sourceCurrency: Currency): AmountWithCurrency { - return exchangeRates.convert(date, this, sourceCurrency.isoCode).withCurrency(exchangeRates.targetCurrencyCode) - } - - protected suspend fun WithAmounts.renderAmount(date: YearMonthDay): AmountWithCurrency { - return when { - MarketplaceCurrencies.USD.hasCode(exchangeRates.targetCurrencyCode) -> amountUSD.withCurrency(MarketplaceCurrencies.USD) - currency.hasCode(exchangeRates.targetCurrencyCode) -> amount.withCurrency(currency) - else -> exchangeRates.convert(date, this.amount, this.currency.isoCode).withCurrency(exchangeRates.targetCurrencyCode) + protected fun WithAmounts.renderAmount(): MonetaryAmount { + return when (exchangeRates.targetCurrency) { + MarketplaceCurrencies.USD -> amountUSD + else -> this.amount } } - protected suspend fun WithAmounts.renderFeeAmount(date: YearMonthDay): AmountWithCurrency { - val total = this.renderAmount(date) - return Marketplace.feeAmount(date, total.amount).withCurrency(total.currencyCode) + protected fun WithAmounts.renderFeeAmount(date: YearMonthDay): MonetaryAmount { + return Marketplace.feeAmount(date, renderAmount()) } - protected suspend fun WithAmounts.renderPaidAmount(date: YearMonthDay): AmountWithCurrency { - val total = this.renderAmount(date) - return Marketplace.paidAmount(date, total.amount).withCurrency(total.currencyCode) + protected fun WithAmounts.renderPaidAmount(date: YearMonthDay): MonetaryAmount { + return Marketplace.paidAmount(date, renderAmount()) } } diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/LicenseInfo.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/LicenseInfo.kt index fc14358..a60efd2 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/LicenseInfo.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/LicenseInfo.kt @@ -6,8 +6,7 @@ package dev.ja.marketplace.data import dev.ja.marketplace.client.* -import dev.ja.marketplace.services.Currency -import java.math.BigInteger +import javax.money.MonetaryAmount typealias LicenseId = dev.ja.marketplace.client.LicenseId @@ -20,16 +19,17 @@ data class LicenseInfo( // dates, when this license is valid val validity: YearMonthDayRange, // amount of this particular license - override val amount: Amount, - // currency of Amount - override val currency: Currency, + override val amount: MonetaryAmount, // same as amount, but converted from "currency" to USD - override val amountUSD: Amount, + override val amountUSD: MonetaryAmount, // the sale of this particular license purchase, which also contains the saleLineItem val sale: PluginSale, // the sale line item of this particular license purchase val saleLineItem: PluginSaleItem, ) : WithDateRange, WithAmounts, Comparable { + init { + require(amountUSD.currency.currencyCode == "USD") + } val isNewLicense: Boolean get() { @@ -43,7 +43,7 @@ data class LicenseInfo( val isPaidLicense: Boolean get() { - return amountUSD != Amount.ZERO && !saleLineItem.isFreeLicense + return !amountUSD.isZero && !saleLineItem.isFreeLicense } override val dateRange: YearMonthDayRange @@ -55,42 +55,23 @@ data class LicenseInfo( companion object { fun create(sales: List): List { - return sales.flatMap { sale -> - val licenses = mutableListOf() - - for (lineItem in sale.lineItems) { - val fixedAmount = when (sale.amount.toDouble()) { - 0.0 -> Amount(BigInteger.ZERO) - else -> lineItem.amount - } - val fixedAmountUSD = when (sale.amountUSD.toDouble()) { - 0.0 -> Amount(BigInteger.ZERO) - else -> lineItem.amountUSD - } - - SplitAmount.split(fixedAmount, fixedAmountUSD, lineItem.licenseIds) { amount, amountUSD, license -> + val licenses = mutableListOf() + sales.forEach { sale -> + sale.lineItems.forEach { lineItem -> + SplitAmount.split(lineItem.amount, lineItem.amountUSD, lineItem.licenseIds) { amount, amountUSD, license -> licenses += LicenseInfo( license, lineItem.subscriptionDates, amount, - sale.currency, amountUSD, sale, lineItem ) } } - - /*if (licenses.sumOf { it.amountUSD }.toDouble() != sale.amountUSD.toDouble()) { - println( - "Sum does not match: $sale. item sum: ${ - licenses.sumOf { it.amountUSD }.toDouble() - }, total: ${sale.amountUSD.toDouble()}" - ) - }*/ - - licenses.sorted() } + licenses.sort() + return licenses } } } diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/PluginPricing.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/PluginPricing.kt index e173a1e..c7cc0bb 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/PluginPricing.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/PluginPricing.kt @@ -7,21 +7,44 @@ package dev.ja.marketplace.data import dev.ja.marketplace.client.* import dev.ja.marketplace.services.Countries +import org.javamoney.moneta.FastMoney +import org.javamoney.moneta.Money +import java.util.concurrent.ConcurrentHashMap +import javax.money.MonetaryAmount + +private data class PricingCacheKey( + val customerInfo: CustomerInfo, + val licensePeriod: LicensePeriod, + val continuityDiscount: ContinuityDiscount, +) + +private val NoBasePriceValue = Money.of(-1111.11, MarketplaceCurrencies.USD) data class PluginPricing( private val countries: Countries, private val countryCodeToPricing: Map ) { + private val basePriceCache = ConcurrentHashMap() + fun getCountryPricing(countryIsoCode: String): PluginPriceInfo? { return countryCodeToPricing[countryIsoCode] } fun getBasePrice( - date: YearMonthDay, customerInfo: CustomerInfo, licensePeriod: LicensePeriod, continuityDiscount: ContinuityDiscount, - ): AmountWithCurrency? { + ): MonetaryAmount? { + return basePriceCache.computeIfAbsent(PricingCacheKey(customerInfo, licensePeriod, continuityDiscount)) { + getBasePriceInner(customerInfo, licensePeriod, continuityDiscount) ?: NoBasePriceValue + }.takeIf { it !== NoBasePriceValue } + } + + private fun getBasePriceInner( + customerInfo: CustomerInfo, + licensePeriod: LicensePeriod, + continuityDiscount: ContinuityDiscount + ): MonetaryAmount? { val countryWithCurrency = countries.byCountryName(customerInfo.country) ?: throw IllegalStateException("unable to find country for name ${customerInfo.country}") val priceInfo = countryCodeToPricing[countryWithCurrency.country.isoCode] ?: return null @@ -40,7 +63,7 @@ data class PluginPricing( ContinuityDiscount.ThirdYear -> pricing.thirdYear } - return AmountWithCurrency(withDiscount.price, countryWithCurrency.currency) + return FastMoney.of(withDiscount.price, countryWithCurrency.currency.isoCode) } companion object { diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SaleCalculator.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SaleCalculator.kt deleted file mode 100644 index 48e019e..0000000 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SaleCalculator.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2023 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.data - -import dev.ja.marketplace.client.PluginSaleItemDiscount -import dev.ja.marketplace.client.WithAmounts -import java.math.BigDecimal - -/** - * - */ -class SaleCalculator { - fun nextSale(licenseInfo: LicenseInfo): WithAmounts { - if (licenseInfo.saleLineItem.discountDescriptions.any { it.isFreeLicenseDiscount() }) { - return Amounts.zero(licenseInfo.currency) - } - - - val resellerDiscount = licenseInfo.saleLineItem.discountDescriptions - .filter { it.isResellerDiscount() } - .sumOf { it.percent ?: 0.0 } - val continuityDiscount = licenseInfo.saleLineItem.discountDescriptions - .firstOrNull { it.isContinuityDiscount() } - ?.percent - val nextContinuityDiscount = when (continuityDiscount) { - null -> 20.0 - 20.0 -> 40.0 - else -> 40.0 - } - - val discountFactor = BigDecimal(1.0 * (1.0 - resellerDiscount / 100.0) * (1.0 - nextContinuityDiscount / 100.0)) - return Amounts( - licenseInfo.amount * discountFactor, - licenseInfo.currency, - licenseInfo.amountUSD * discountFactor - ) - } - - companion object { - private fun PluginSaleItemDiscount.isFreeLicenseDiscount(): Boolean { - return this.percent == 100.0 - } - - private fun PluginSaleItemDiscount.isResellerDiscount(): Boolean { - return this.description.contains("Reseller discount for 3rd-party plugins") - } - - private fun PluginSaleItemDiscount.isContinuityDiscount(): Boolean { - return this.description.contains("continuity discount") - } - } -} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SplitAmount.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SplitAmount.kt index 2a0c992..5d170ae 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SplitAmount.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/SplitAmount.kt @@ -1,48 +1,53 @@ /* - * Copyright (c) 2023 Joachim Ansorg. + * Copyright (c) 2023-2024 Joachim Ansorg. * SPDX-License-Identifier: AGPL-3.0-or-later */ package dev.ja.marketplace.data -import dev.ja.marketplace.client.Amount +import org.javamoney.moneta.FastMoney import java.math.BigDecimal -import java.math.RoundingMode.DOWN +import java.math.RoundingMode +import javax.money.MonetaryAmount /** * Split an amount into parts without errors by rounding. */ object SplitAmount { fun split( - total: Amount, - totalUSD: Amount, + total: MonetaryAmount, + totalUSD: MonetaryAmount, items: List, - block: (amount: Amount, amountUSD: Amount, item: T) -> Unit + block: (amount: MonetaryAmount, amountUSD: MonetaryAmount, item: T) -> Unit ) { val size = items.size - val count = size.toBigDecimal() - - if (size == 0) { - return - } - if (size == 1) { - block(total, totalUSD, items[0]) - return + when (size) { + 0 -> return + 1 -> { + block(total, totalUSD, items[0]) + return + } } - val totalScaled = total.setScale(10, DOWN) - val itemAmount = (totalScaled / count).setScale(2, DOWN) - val itemAmountLast = (totalScaled - itemAmount * (count - BigDecimal.ONE)).setScale(2, DOWN) + val count = size.toBigDecimal() - val totalUSDScaled = totalUSD.setScale(10, DOWN) - val itemAmountUSD = (totalUSDScaled / count).setScale(2, DOWN) - val itemAmountUSDLast = (totalUSDScaled - itemAmountUSD * (count - BigDecimal.ONE)).setScale(2, DOWN) + val totalScaled = total.number.numberValue(BigDecimal::class.java).setScale(10, RoundingMode.DOWN) + val itemAmount = (totalScaled / count).setScale(2, RoundingMode.DOWN) + val itemAmountLast = (totalScaled - itemAmount * (count - BigDecimal.ONE)).setScale(2, RoundingMode.DOWN) - items.forEachIndexed { index, item -> - when (index) { - size - 1 -> block(itemAmountLast, itemAmountUSDLast, item) - else -> block(itemAmount, itemAmountUSD, item) - } + val totalUSDScaled = totalUSD.number.numberValue(BigDecimal::class.java).setScale(10, RoundingMode.DOWN) + val itemAmountUSD = (totalUSDScaled / count).setScale(2, RoundingMode.DOWN) + val itemAmountUSDLast = (totalUSDScaled - itemAmountUSD * (count - BigDecimal.ONE)).setScale(2, RoundingMode.DOWN) + + val it = items.iterator() + while (it.hasNext()) { + val item = it.next() + val isLast = !it.hasNext() + block( + FastMoney.of(if (isLast) itemAmountLast else itemAmount, total.currency), + FastMoney.of(if (isLast) itemAmountUSDLast else itemAmountUSD, "USD"), + item + ) } } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customerType/CustomerTypeTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customerType/CustomerTypeTable.kt index 620d0ab..d99d5bb 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customerType/CustomerTypeTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customerType/CustomerTypeTable.kt @@ -8,7 +8,7 @@ package dev.ja.marketplace.data.customerType import dev.ja.marketplace.client.CustomerType import dev.ja.marketplace.client.PluginSale import dev.ja.marketplace.data.* -import dev.ja.marketplace.data.trackers.AmountTargetCurrencyTracker +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker import java.util.* class CustomerTypeTable : SimpleDataTable("Customer Type", "customer-type"), MarketplaceDataSink { @@ -16,22 +16,22 @@ class CustomerTypeTable : SimpleDataTable("Customer Type", "customer-type"), Mar private val columnAmount = DataTableColumn("amount", null, "num") private val columnPercentage = DataTableColumn("percentage", "% of Sales", "num num-percentage") - private lateinit var totalAmount: AmountTargetCurrencyTracker - private val customerTypes = TreeMap() + private lateinit var totalAmount: MonetaryAmountTracker + private val customerTypes = TreeMap() override val columns: List = listOf(columnType, columnAmount, columnPercentage) override suspend fun init(data: PluginData) { super.init(data) - totalAmount = AmountTargetCurrencyTracker(data.exchangeRates) + totalAmount = MonetaryAmountTracker(data.exchangeRates) } override suspend fun process(sale: PluginSale) { - totalAmount.add(sale.date, sale.amountUSD, sale.amount, sale.currency) + totalAmount.add(sale.date, sale.amountUSD, sale.amount) - val tracker = customerTypes.computeIfAbsent(sale.customer.type) { AmountTargetCurrencyTracker(exchangeRates) } - tracker.add(sale.date, sale.amountUSD, sale.amount, sale.currency) + val tracker = customerTypes.computeIfAbsent(sale.customer.type) { MonetaryAmountTracker(exchangeRates) } + tracker.add(sale.date, sale.amountUSD, sale.amount) } override suspend fun process(licenseInfo: LicenseInfo) { @@ -45,7 +45,7 @@ class CustomerTypeTable : SimpleDataTable("Customer Type", "customer-type"), Mar SimpleDateTableRow( columnType to customerType, columnAmount to amount.getTotalAmount(), - columnPercentage to PercentageValue.of(amount.getTotalAmount().amount, totalAmount.amount) + columnPercentage to PercentageValue.of(amount.getTotalAmount(), totalAmount) ) } diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customers/CustomerTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customers/CustomerTable.kt index 7cb233b..406758c 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customers/CustomerTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/customers/CustomerTable.kt @@ -11,11 +11,12 @@ import dev.ja.marketplace.client.LicenseId import dev.ja.marketplace.client.PluginId import dev.ja.marketplace.client.YearMonthDay import dev.ja.marketplace.data.* -import dev.ja.marketplace.data.trackers.AmountTargetCurrencyTracker +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker +import dev.ja.marketplace.util.sortValue data class CustomerTableRowData( val customer: CustomerInfo, - var totalSales: AmountTargetCurrencyTracker, + var totalSales: MonetaryAmountTracker, var earliestLicenseStart: YearMonthDay? = null, var latestLicenseEnd: YearMonthDay? = null, val totalLicenses: MutableSet = mutableSetOf(), @@ -39,7 +40,7 @@ class CustomerTable( private val columnId = DataTableColumn("customer-id", "Cust. ID", "num") private val customerMap = mutableMapOf() - private lateinit var totalSales: AmountTargetCurrencyTracker + private lateinit var totalSales: MonetaryAmountTracker private var pluginId: PluginId? = null @@ -59,15 +60,15 @@ class CustomerTable( super.init(data) this.pluginId = data.pluginId - this.totalSales = AmountTargetCurrencyTracker(exchangeRates) + this.totalSales = MonetaryAmountTracker(exchangeRates) } override suspend fun process(licenseInfo: LicenseInfo) { - totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) + totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) val customer = licenseInfo.sale.customer val data = customerMap.computeIfAbsent(customer.code) { - CustomerTableRowData(customer, AmountTargetCurrencyTracker(exchangeRates)) + CustomerTableRowData(customer, MonetaryAmountTracker(exchangeRates)) } val licenseStart = licenseInfo.validity.start @@ -81,14 +82,14 @@ class CustomerTable( if (licenseInfo.validity.end >= nowDate) { data.activeLicenses += licenseInfo.id } - data.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) + data.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) } override suspend fun createSections(): List { var prevValidUntil: YearMonthDay? = null val displayedCustomers = customerMap.values .filter(customerFilter) - .sortedByDescending { it.totalSales.getTotalAmount().amount.sortValue() } + .sortedByDescending { it.totalSales.getTotalAmount().sortValue() } .sortedByDescending { it.latestLicenseEnd!! } val rows = displayedCustomers @@ -124,7 +125,7 @@ class CustomerTable( sortValues = mapOf( columnValidUntil to validUntil.sortValue, columnValidSince to validSince.sortValue, - columnSales to customerData.totalSales.getTotalAmount().amount.sortValue(), + columnSales to customerData.totalSales.getTotalAmount().sortValue(), ), ) } diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/daySummary/DaySummaryTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/daySummary/DaySummaryTable.kt index 660a94a..0cf5bd0 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/daySummary/DaySummaryTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/daySummary/DaySummaryTable.kt @@ -7,13 +7,17 @@ package dev.ja.marketplace.data.daySummary import dev.ja.marketplace.client.* import dev.ja.marketplace.data.* +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker +import dev.ja.marketplace.util.sortValue import java.math.BigInteger +import javax.money.MonetaryAmount class DaySummaryTable( val date: YearMonthDay, title: String ) : SimpleDataTable(title, cssClass = "small table-striped"), MarketplaceDataSink { - private val sales = mutableListOf() + private lateinit var totalSales: MonetaryAmountTracker + private val sales = mutableMapOf, MonetaryAmountTracker>() private lateinit var trials: List private val columnSubscriptionType = DataTableColumn("sales-subscription", null, "col-right") @@ -28,6 +32,14 @@ class DaySummaryTable( override suspend fun init(data: PluginData) { super.init(data) + this.totalSales = MonetaryAmountTracker(data.exchangeRates) + + for (type in CustomerType.entries) { + for (period in LicensePeriod.entries) { + sales[type to period] = MonetaryAmountTracker(exchangeRates) + } + } + trials = data.trials ?.filter { it.date == date } ?.sortedBy { it.customer.country } @@ -36,7 +48,11 @@ class DaySummaryTable( override suspend fun process(sale: PluginSale) { if (sale.date == date) { - sales += sale + totalSales.add(sale.date, sale.amountUSD, sale.amount) + + val tracker = sales[sale.customer.type to sale.licensePeriod] + ?: throw IllegalStateException("Unable to find sales for $sale") + tracker.add(sale.date, sale.amountUSD, sale.amount) } } @@ -45,18 +61,15 @@ class DaySummaryTable( } override suspend fun createSections(): List { - val salesTable = sales - .groupBy { it.customer.type } - .mapValues { it.value.groupBy { sale -> sale.licensePeriod } } - .flatMap { (type, licensePeriodWithSales) -> - licensePeriodWithSales.map { (licensePeriod, sales) -> - SimpleDateTableRow( - columnCustomerType to type, - columnSubscriptionType to licensePeriod, - columnAmount to sales.sumOf { it.amountUSD }.render(date, MarketplaceCurrencies.USD), - ) - } - }.sortedByDescending { it.values[columnAmount] as? AmountWithCurrency } + val salesTable = sales.map { + SimpleDateTableRow( + columnCustomerType to it.key.first, + columnSubscriptionType to it.key.second, + columnAmount to it.value.getTotalAmount() + ) + }.sortedByDescending { + (it.values[columnAmount] as MonetaryAmount).sortValue() + } val trialRows = trials .groupBy { it.customer.type } @@ -75,8 +88,7 @@ class DaySummaryTable( SimpleTableSection( rows = salesTable, columns = listOf(columnSubscriptionType, columnCustomerType, columnAmount), - footer = SimpleRowGroup(SimpleDateTableRow(columnAmount to sales.sumOf { it.amountUSD } - .render(date, MarketplaceCurrencies.USD))) + footer = SimpleRowGroup(SimpleDateTableRow(columnAmount to totalSales.getTotalAmount())) ), SimpleTableSection( title = "", diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/format/Formatters.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/format/Formatters.kt new file mode 100644 index 0000000..4cb1b64 --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/format/Formatters.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.data.format + +import java.util.* +import javax.money.format.MonetaryFormats + +object Formatters { + val MonetaryAmount = MonetaryFormats.getAmountFormat(Locale.getDefault()) +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/licenses/LicenseTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/licenses/LicenseTable.kt index 239f9c2..1ea440d 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/licenses/LicenseTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/licenses/LicenseTable.kt @@ -10,6 +10,7 @@ import dev.ja.marketplace.client.YearMonthDay import dev.ja.marketplace.client.YearMonthDayRange import dev.ja.marketplace.data.* import dev.ja.marketplace.data.trackers.PaymentAmountTracker +import dev.ja.marketplace.util.sortValue import dev.ja.marketplace.util.takeNullable class LicenseTable( @@ -114,7 +115,7 @@ class LicenseTable( val showPurchaseDate = previousPurchaseDate != purchaseDate previousPurchaseDate = purchaseDate - amountTracker.add(license.sale.date, license.amountUSD, license.amount, license.currency) + amountTracker.add(license.sale.date, license.amountUSD, license.amount) SimpleDateTableRow( values = mapOf( @@ -125,7 +126,7 @@ class LicenseTable( columnValidityEnd to license.validity.end, columnCustomerName to (license.sale.customer.name ?: NoValue), columnCustomerId to LinkedCustomer(license.sale.customer.code, pluginId = pluginId!!), - columnAmount to license.renderAmount(purchaseDate), + columnAmount to license.renderAmount(), columnAmountFee to license.renderFeeAmount(purchaseDate), columnAmountPaid to license.renderPaidAmount(purchaseDate), columnLicenseType to license.sale.licensePeriod, diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/OverviewTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/OverviewTable.kt index 52cd85f..05a362d 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/OverviewTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/OverviewTable.kt @@ -6,9 +6,9 @@ package dev.ja.marketplace.data.overview import dev.ja.marketplace.churn.ChurnProcessor -import dev.ja.marketplace.churn.MarketplaceChurnProcessor +import dev.ja.marketplace.churn.CustomerChurnProcessor +import dev.ja.marketplace.churn.LicenseChurnProcessor import dev.ja.marketplace.client.* -import dev.ja.marketplace.client.LicenseId import dev.ja.marketplace.data.* import dev.ja.marketplace.data.overview.OverviewTable.CustomerSegment.* import dev.ja.marketplace.data.trackers.* @@ -45,10 +45,10 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab val customers: CustomerTracker, val licenses: LicenseTracker, val amounts: PaymentAmountTracker, - val churnCustomersAnnual: ChurnProcessor, - val churnLicensesAnnual: ChurnProcessor, - val churnCustomersMonthly: ChurnProcessor, - val churnLicensesMonthly: ChurnProcessor, + val churnCustomersAnnual: ChurnProcessor, + val churnLicensesAnnual: ChurnProcessor, + val churnCustomersMonthly: ChurnProcessor, + val churnLicensesMonthly: ChurnProcessor, val mrrTracker: RecurringRevenueTracker, val arrTracker: RecurringRevenueTracker, val downloads: Long, @@ -62,10 +62,10 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab private data class YearData( val year: Int, - val churnCustomersAnnual: ChurnProcessor, - val churnLicensesAnnual: ChurnProcessor, - val churnCustomersMonthly: ChurnProcessor, - val churnLicensesMonthly: ChurnProcessor, + val churnCustomersAnnual: ChurnProcessor, + val churnLicensesAnnual: ChurnProcessor, + val churnCustomersMonthly: ChurnProcessor, + val churnLicensesMonthly: ChurnProcessor, val months: Map, val trials: TrialTracker, ) { @@ -86,12 +86,6 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab private val columnAmountTotal = DataTableColumn("sales", "Total Sales", "num") private val columnAmountFees = DataTableColumn("sales", "Fees", "num") private val columnAmountPaid = DataTableColumn("sales", "Invoice", "num") - private val columnActiveCustomers = DataTableColumn( - "customer-count", "Cust.", "num", tooltip = "Customers at the end of month" - ) - private val columnActiveCustomersPaying = DataTableColumn( - "customer-count-paying", "Paying", "num", tooltip = "Paying customers at the end of month" - ) private val columnActiveLicenses = DataTableColumn( "customer-licenses", "Licenses", "num", tooltip = "Licenses at the end of month" ) @@ -105,16 +99,10 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab "customer-mrr", "ARR", "num", tooltip = "Average annual recurring revenue (MRR)" ) - // private val columnCustomerChurnAnnual = DataTableColumn( -// "churn-annual-paid", "Cust. churn (annual)", "num num-percentage", tooltip = "Churn of customers with annual licenses" -// ) private val columnLicenseChurnAnnual = DataTableColumn( "churn-annual-paid", "Churn (annual)", "num num-percentage", tooltip = "Churn of paid annual licenses" ) - // private val columnCustomerChurnMonthly = DataTableColumn( -// "churn-monthly-paid", "Cust. churn (monthly)", "num num-percentage", tooltip = "Churn of customers with paid monthly licenses" -// ) private val columnLicenseChurnMonthly = DataTableColumn( "churn-monthly-paid", "Churn (monthly)", "num num-percentage", tooltip = "Churn of paid monthly licenses" ) @@ -131,18 +119,13 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab override val columns: List = listOfNotNull( columnYearMonth, columnAmountTotal, - //columnAmountFeesUSD, columnAmountPaid, - //columnActiveCustomers, - //columnActiveCustomersPaying, columnActiveLicenses, columnActiveLicensesPaying, columnMonthlyRecurringRevenue, columnAnnualRecurringRevenue, columnLicenseChurnAnnual, columnLicenseChurnMonthly, -// columnCustomerChurnAnnual.takeIf { showCustomerChurn }, -// columnCustomerChurnMonthly.takeIf { showCustomerChurn }, columnDownloads, columnTrials, columnTrialsConverted, @@ -236,7 +219,7 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab yearData.trials.processSale(sale) val monthData = yearData.months[sale.date.month]!! - monthData.amounts.add(sale.date, sale.amountUSD, sale.amount, sale.currency) + monthData.amounts.add(sale.date, sale.amountUSD, sale.amount) monthData.trials.processSale(sale) } @@ -249,14 +232,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab years.values.forEach { year -> year.churnCustomersAnnual.processValue( - customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Annual && isPaidLicense, isRenewal ) year.churnLicensesAnnual.processValue( - licenseInfo.id, licenseInfo, licenseInfo.validity, licensePeriod == LicensePeriod.Annual && isPaidLicense, @@ -264,14 +245,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab ) year.churnCustomersMonthly.processValue( - customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly && isPaidLicense, isRenewal ) year.churnLicensesMonthly.processValue( - licenseInfo.id, licenseInfo, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly && isPaidLicense, @@ -286,14 +265,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab month.arrTracker.processLicenseSale(licenseInfo) month.churnCustomersAnnual.processValue( - customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Annual && isPaidLicense, isRenewal ) month.churnLicensesAnnual.processValue( - licenseInfo.id, licenseInfo, licenseInfo.validity, licensePeriod == LicensePeriod.Annual && isPaidLicense, @@ -301,14 +278,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab ) month.churnCustomersMonthly.processValue( - customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly && isPaidLicense, isRenewal ) month.churnLicensesMonthly.processValue( - licenseInfo.id, licenseInfo, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly && isPaidLicense, @@ -321,12 +296,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab override suspend fun createSections(): List { val now = YearMonthDay.now() val pluginId = pluginId!! + return years.entries .toMutableList() .dropLastWhile { it.value.isEmpty } // don't show empty years .map { (year, yearData) -> val rows = yearData.months.entries.map { (month, monthData) -> - val lastOfMonth = YearMonthDay.lastOfMonth(year, month) val isCurrentMonth = now.year == year && now.month == month val mrrResult = when { @@ -339,34 +314,27 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab else -> monthData.arrTracker.getResult() } - val mrrValue = mrrResult?.amounts?.getConvertedResult(lastOfMonth) - val arrValue = arrResult?.amounts?.getConvertedResult(lastOfMonth) + val mrrValue = mrrResult?.amounts?.getTotalAmount() + val arrValue = arrResult?.amounts?.getTotalAmount() - val annualLicenseChurn = monthData.churnLicensesAnnual.getResult(LicensePeriod.Annual).takeUnless { isCurrentMonth } + val annualLicenseChurn = when { + isCurrentMonth -> null + else -> monthData.churnLicensesAnnual.getResult(LicensePeriod.Annual) + } val annualLicenseChurnRate = annualLicenseChurn?.getRenderedChurnRate(pluginId) - val monthlyLicenseChurn = monthData.churnLicensesMonthly.getResult(LicensePeriod.Monthly).takeUnless { isCurrentMonth } + val monthlyLicenseChurn = when { + isCurrentMonth -> null + else -> monthData.churnLicensesMonthly.getResult(LicensePeriod.Monthly) + } val monthlyLicenseChurnRate = monthlyLicenseChurn?.getRenderedChurnRate(pluginId) - val annualCustomerChurn = monthData.churnCustomersAnnual.getResult(LicensePeriod.Annual).takeUnless { isCurrentMonth } - val annualCustomerChurnRate = annualCustomerChurn?.getRenderedChurnRate(pluginId) - - val monthlyCustomerChurn = - monthData.churnCustomersMonthly.getResult(LicensePeriod.Monthly).takeUnless { isCurrentMonth } - val monthlyCustomerChurnRate = monthlyCustomerChurn?.getRenderedChurnRate(pluginId) - val cssClass = when { isCurrentMonth -> "today" monthData.isEmpty -> "disabled" else -> null } - val annualCustomersFree = monthData.customers.segmentCustomerCount(AnnualFree) - val annualCustomersPaying = monthData.customers.segmentCustomerCount(AnnualPaying) - val monthlyCustomersPaying = monthData.customers.segmentCustomerCount(MonthlyPaying) - val totalCustomers = monthData.customers.totalCustomerCount - val totalCustomersPaying = monthData.customers.payingCustomerCount - val annualLicensesFree = monthData.licenses.segmentCustomerCount(AnnualFree) val annualLicensesPaying = monthData.licenses.segmentCustomerCount(AnnualPaying) val monthlyLicensesPaying = monthData.licenses.segmentCustomerCount(MonthlyPaying) @@ -402,8 +370,6 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab SimpleDateTableRow( values = mapOf( columnYearMonth to String.format("%02d-%02d", year, month), - columnActiveCustomers to totalCustomers.toBigInteger(), - columnActiveCustomersPaying to totalCustomersPaying.toBigInteger(), columnActiveLicenses to totalLicenses.toBigInteger(), columnActiveLicensesPaying to totalLicensesPaying.toBigInteger(), columnMonthlyRecurringRevenue to (mrrValue ?: NoValue), @@ -427,20 +393,12 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab columnLicenseChurnAnnual to annualLicenseChurn?.churnRateTooltip, columnLicenseChurnMonthly to monthlyLicenseChurn?.churnRateTooltip, columnTrialsConverted to trialsMonth.tooltipConverted, - // fixme drop - columnActiveCustomers to "$annualCustomersPaying annual (paying)" + - "\n$annualCustomersFree annual (free)" + - "\n$monthlyCustomersPaying monthly (paying)", - columnActiveCustomersPaying to "$annualCustomersPaying annual\n$monthlyCustomersPaying monthly", ), cssClass = cssClass ) } - val yearCustomerChurnAnnual = yearData.churnCustomersAnnual.getResult(LicensePeriod.Annual) val yearLicenseChurnAnnual = yearData.churnLicensesAnnual.getResult(LicensePeriod.Annual) - - val yearCustomerChurnMonthly = yearData.churnCustomersMonthly.getResult(LicensePeriod.Monthly) val yearLicenseChurnMonthly = yearData.churnLicensesMonthly.getResult(LicensePeriod.Monthly) val trialsYear = yearData.trials.getResult() @@ -478,18 +436,18 @@ class OverviewTable : SimpleDataTable("Overview", "overview", "table-striped tab } } - private fun createCustomerChurnProcessor(timeRange: YearMonthDayRange): ChurnProcessor { + private fun createCustomerChurnProcessor(timeRange: YearMonthDayRange): ChurnProcessor { val churnDate = timeRange.end val activeDate = timeRange.start.add(0, 0, -1) - val processor = MarketplaceChurnProcessor(activeDate, churnDate, ::HashSet) + val processor = CustomerChurnProcessor(activeDate, churnDate) processor.init() return processor } - private fun createLicenseChurnProcessor(timeRange: YearMonthDayRange): ChurnProcessor { + private fun createLicenseChurnProcessor(timeRange: YearMonthDayRange): ChurnProcessor { val churnDate = timeRange.end val activeDate = timeRange.start.add(0, 0, -1) - val processor = MarketplaceChurnProcessor(activeDate, churnDate, ::HashSet) + val processor = LicenseChurnProcessor(activeDate, churnDate) processor.init() return processor } diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/privingOverview/PricingOverviewTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/privingOverview/PricingOverviewTable.kt index 7baa097..29e3684 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/privingOverview/PricingOverviewTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/privingOverview/PricingOverviewTable.kt @@ -5,11 +5,18 @@ package dev.ja.marketplace.data.privingOverview -import dev.ja.marketplace.client.* +import dev.ja.marketplace.client.MarketplaceCurrencies +import dev.ja.marketplace.client.PluginPriceInfo +import dev.ja.marketplace.client.PriceInfoTypeData import dev.ja.marketplace.data.* import dev.ja.marketplace.services.Countries import dev.ja.marketplace.services.CountryWithCurrency +import dev.ja.marketplace.services.Currency +import dev.ja.marketplace.util.sortValue +import org.javamoney.moneta.Money +import java.math.BigDecimal import java.text.Collator +import javax.money.MonetaryAmount class PricingOverviewTable : SimpleDataTable("Pricing", "pricing", "table-column-wide"), MarketplaceDataSink { @@ -60,7 +67,7 @@ class PricingOverviewTable : SimpleDataTable("Pricing", "pricing", "table-column ) val currencyToPricing = MarketplaceCurrencies.associate { currency -> - currency.isoCode to countries.byCurrencyIsoCode(currency.isoCode)!!.mapNotNull { countryWithCurrency -> + currency to countries.byCurrencyIsoCode(currency.currencyCode)!!.mapNotNull { countryWithCurrency -> val pricing = pluginPricing.getCountryPricing(countryWithCurrency.country.isoCode) ?: return@mapNotNull null countryWithCurrency to pricing } @@ -94,23 +101,23 @@ class PricingOverviewTable : SimpleDataTable("Pricing", "pricing", "table-column columnThirdYearCommercial to listOfNotNull(commercialC, commercialTaxedC), ), sortValues = mapOf( - columnFirstYearPersonal to personalA.amount.sortValue(), - columnSecondYearPersonal to personalB.amount.sortValue(), - columnThirdYearPersonal to personalC.amount.sortValue(), - columnFirstYearCommercial to commercialA.amount.sortValue(), - columnSecondYearCommercial to commercialB.amount.sortValue(), - columnThirdYearCommercial to commercialC.amount.sortValue(), + columnFirstYearPersonal to personalA.sortValue(), + columnSecondYearPersonal to personalB.sortValue(), + columnThirdYearPersonal to personalC.sortValue(), + columnFirstYearCommercial to commercialA.sortValue(), + columnSecondYearCommercial to commercialB.sortValue(), + columnThirdYearCommercial to commercialC.sortValue(), ) ) } - SimpleTableSection(rows = subTableRows, columns = subColumns, title = currencyCode) + SimpleTableSection(rows = subTableRows, columns = subColumns, title = currencyCode.currencyCode) } return currencySections } - private fun PriceInfoTypeData.mapYears(currency: dev.ja.marketplace.services.Currency): Triple { + private fun PriceInfoTypeData.mapYears(currency: Currency): Triple { return Triple( firstYear.price.withCurrency(currency), secondYear.price.withCurrency(currency), @@ -118,11 +125,15 @@ class PricingOverviewTable : SimpleDataTable("Pricing", "pricing", "table-column ) } - private fun PriceInfoTypeData.mapYearsTaxed(currency: dev.ja.marketplace.services.Currency): Triple { + private fun PriceInfoTypeData.mapYearsTaxed(currency: Currency): Triple { return Triple( firstYear.priceTaxed?.withCurrency(currency), secondYear.priceTaxed?.withCurrency(currency), thirdYear.priceTaxed?.withCurrency(currency), ) } + + private fun BigDecimal.withCurrency(currency: Currency): MonetaryAmount { + return Money.of(this, currency.isoCode) + } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/resellers/ResellerTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/resellers/ResellerTable.kt index d7d4c22..fa7365c 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/resellers/ResellerTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/resellers/ResellerTable.kt @@ -5,31 +5,34 @@ package dev.ja.marketplace.data.resellers -import dev.ja.marketplace.client.* +import dev.ja.marketplace.client.CustomerId +import dev.ja.marketplace.client.PluginSale +import dev.ja.marketplace.client.ResellerInfo import dev.ja.marketplace.data.* -import dev.ja.marketplace.data.LicenseId -import dev.ja.marketplace.data.trackers.AmountTargetCurrencyTracker +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker +import dev.ja.marketplace.util.sortValue import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap import java.util.function.IntFunction +import javax.money.MonetaryAmount private data class ResellerTableRow( val resellerInfo: ResellerInfo, - var totalSales: AmountTargetCurrencyTracker, + var totalSales: MonetaryAmountTracker, var licenses: MutableSet = mutableSetOf(), var customers: MutableSet = mutableSetOf(), ) class ResellerTable : SimpleDataTable("Resellers", cssClass = "table-column-wide sortable"), MarketplaceDataSink { private val data = Int2ObjectOpenHashMap() - private lateinit var totalSales: AmountTargetCurrencyTracker + private lateinit var totalSales: MonetaryAmountTracker - private val columnCode = DataTableColumn("reseller-code", "Code") private val columnName = DataTableColumn("reseller-name", "Name") - private val columnCountry = DataTableColumn("reseller-country", "Country") - private val columnType = DataTableColumn("reseller-type", "Type") + private val columnTotalSales = DataTableColumn("reseller-sales", "Total Sales", preSorted = AriaSortOrder.Descending) private val columnCustomerCount = DataTableColumn("reseller-customers", "Customers") private val columnLicenseCount = DataTableColumn("reseller-licenses", "Licenses Sold") - private val columnTotalSales = DataTableColumn("reseller-sales", "Total Sales", preSorted = AriaSortOrder.Descending) + private val columnCountry = DataTableColumn("reseller-country", "Country") + private val columnType = DataTableColumn("reseller-type", "Type") + private val columnCode = DataTableColumn("reseller-code", "Code") override val columns: List = listOf( columnName, @@ -44,7 +47,25 @@ class ResellerTable : SimpleDataTable("Resellers", cssClass = "table-column-wide override suspend fun init(data: PluginData) { super.init(data) - this.totalSales = AmountTargetCurrencyTracker(data.exchangeRates) + this.totalSales = MonetaryAmountTracker(data.exchangeRates) + } + + override suspend fun process(sale: PluginSale) { + val resellerInfo = sale.reseller ?: return + val row = data.computeIfAbsent(resellerInfo.code, IntFunction { + ResellerTableRow(resellerInfo, MonetaryAmountTracker(exchangeRates)) + }) + row.customers += sale.customer.code + } + + override suspend fun process(licenseInfo: LicenseInfo) { + val reseller = licenseInfo.sale.reseller ?: return + + totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) + + val row = data[reseller.code]!! + row.licenses += licenseInfo.id + row.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) } override suspend fun createSections(): List { @@ -60,32 +81,23 @@ class ResellerTable : SimpleDataTable("Resellers", cssClass = "table-column-wide columnTotalSales to row.totalSales.getTotalAmount(), ) } - .sortedByDescending { (it.values[columnTotalSales] as AmountWithCurrency).amount } + .sortedByDescending { (it.values[columnTotalSales] as MonetaryAmount).sortValue() } + val licenseCount = data.values.sumOf { it.licenses.size } + val customerCount = data.values.sumOf { it.customers.size } val footer = SimpleDateTableRow( - columnTotalSales to totalSales.getTotalAmount(), - columnLicenseCount to data.values.sumOf { it.licenses.size }, - columnCustomerCount to data.values.sumOf { it.customers.size }, + values = mapOf( + columnTotalSales to totalSales.getTotalAmount(), + columnLicenseCount to licenseCount.toBigInteger(), + columnCustomerCount to customerCount.toBigInteger(), + ), + sortValues = mapOf( + columnTotalSales to totalSales.getTotalAmount().sortValue(), + columnLicenseCount to licenseCount.toLong(), + columnCustomerCount to customerCount.toLong(), + ) ) return listOf(SimpleTableSection(rows, footer = SimpleRowGroup(footer))) } - - override suspend fun process(sale: PluginSale) { - val resellerInfo = sale.reseller ?: return - val row = data.computeIfAbsent(resellerInfo.code, IntFunction { - ResellerTableRow(resellerInfo, AmountTargetCurrencyTracker(exchangeRates)) - }) - row.customers += sale.customer.code - } - - override suspend fun process(licenseInfo: LicenseInfo) { - val reseller = licenseInfo.sale.reseller ?: return - - totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) - - val row = data[reseller.code]!! - row.licenses += licenseInfo.id - row.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) - } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/timeSpanSummary/TimeSpanSummaryTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/timeSpanSummary/TimeSpanSummaryTable.kt index 746190c..61fadfd 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/timeSpanSummary/TimeSpanSummaryTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/timeSpanSummary/TimeSpanSummaryTable.kt @@ -5,16 +5,17 @@ package dev.ja.marketplace.data.timeSpanSummary -import dev.ja.marketplace.client.* +import dev.ja.marketplace.client.YearMonthDay +import dev.ja.marketplace.client.YearMonthDayRange import dev.ja.marketplace.data.* -import java.math.BigDecimal +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker import java.util.* class TimeSpanSummaryTable(maxDays: Int, title: String) : SimpleDataTable(title, cssClass = "small table-striped"), MarketplaceDataSink { - private data class WeekData( - var sales: Amount, + private data class DaySummary( + var sales: MonetaryAmountTracker, var downloads: Long, var trials: Int, ) @@ -25,25 +26,30 @@ class TimeSpanSummaryTable(maxDays: Int, title: String) : SimpleDataTable(title, private val columnTrials = DataTableColumn("total", "Trials", "num") private val dateRange = YearMonthDay.now().let { YearMonthDayRange(it.add(0, 0, -maxDays), it) } - private val data = TreeMap() + private val daySummaries = TreeMap() + private lateinit var totalSales: MonetaryAmountTracker override val columns: List = listOf(columnDay, columnSales, columnDownloads, columnTrials) override suspend fun init(data: PluginData) { super.init(data) + this.totalSales = MonetaryAmountTracker(exchangeRates) + dateRange.days().forEach { day -> val downloads = data.downloadsDaily.firstOrNull { it.day == day }?.downloads ?: 0 val trials = data.trials?.filter { it.date == day }?.size ?: 0 - this.data[day] = WeekData(BigDecimal.ZERO, downloads, trials) + this.daySummaries[day] = DaySummary(MonetaryAmountTracker(data.exchangeRates), downloads, trials) } } override suspend fun process(licenseInfo: LicenseInfo) { if (licenseInfo.sale.date in dateRange) { - data.compute(licenseInfo.sale.date) { _, current -> + totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) + + daySummaries.compute(licenseInfo.sale.date) { _, current -> current!!.also { - it.sales += licenseInfo.amountUSD + it.sales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) } } } @@ -51,11 +57,11 @@ class TimeSpanSummaryTable(maxDays: Int, title: String) : SimpleDataTable(title, override suspend fun createSections(): List { val now = YearMonthDay.now() - val rows = data.entries.map { (date, weekData) -> + val rows = daySummaries.entries.map { (date, weekData) -> SimpleDateTableRow( mapOf( columnDay to date, - columnSales to weekData.sales.render(date, MarketplaceCurrencies.USD), + columnSales to weekData.sales.getTotalAmount(), columnDownloads to if (date < now) weekData.downloads.toBigInteger() else NoValue, columnTrials to if (date <= now) weekData.trials.toBigInteger() else NoValue, ), @@ -71,11 +77,9 @@ class TimeSpanSummaryTable(maxDays: Int, title: String) : SimpleDataTable(title, SimpleTableSection( rows, footer = SimpleTableSection( SimpleDateTableRow( - columnSales to rows - .sumOf { (it.values[columnSales] as AmountWithCurrency).amount } - .withCurrency(exchangeRates.targetCurrencyCode), - columnDownloads to data.values.sumOf { it.downloads }.toBigInteger(), - columnTrials to data.values.sumOf { it.trials }.toBigInteger(), + columnSales to totalSales.getTotalAmount(), + columnDownloads to daySummaries.values.sumOf { it.downloads }.toBigInteger(), + columnTrials to daySummaries.values.sumOf { it.trials }.toBigInteger(), ) ) ) diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/topCountries/TopCountriesTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/topCountries/TopCountriesTable.kt index c18b61d..42296d2 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/topCountries/TopCountriesTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/topCountries/TopCountriesTable.kt @@ -7,13 +7,14 @@ package dev.ja.marketplace.data.topCountries import dev.ja.marketplace.client.PluginSale import dev.ja.marketplace.data.* -import dev.ja.marketplace.data.trackers.AmountTargetCurrencyTracker +import dev.ja.marketplace.data.trackers.MonetaryAmountTracker import dev.ja.marketplace.data.trackers.SimpleTrialTracker import dev.ja.marketplace.data.trackers.TrialTracker +import dev.ja.marketplace.util.sortValue import java.util.* private data class CountryData( - var totalSales: AmountTargetCurrencyTracker, + var totalSales: MonetaryAmountTracker, var salesCount: Int = 0, var trials: TrialTracker = SimpleTrialTracker(), ) @@ -48,7 +49,7 @@ class TopCountriesTable( private val countries = TreeMap() private val allTrialsTracker: TrialTracker = SimpleTrialTracker() - private lateinit var allSalesTracker: AmountTargetCurrencyTracker + private lateinit var allSalesTracker: MonetaryAmountTracker override val columns: List = listOfNotNull( columnCountry, @@ -62,14 +63,14 @@ class TopCountriesTable( override suspend fun init(data: PluginData) { super.init(data) - allSalesTracker = AmountTargetCurrencyTracker(data.exchangeRates) + allSalesTracker = MonetaryAmountTracker(data.exchangeRates) if (data.trials != null) { for (trial in data.trials) { allTrialsTracker.registerTrial(trial) val countryData = countries.computeIfAbsent(trial.customer.country.orEmptyCountry()) { - CountryData(AmountTargetCurrencyTracker(data.exchangeRates)) + CountryData(MonetaryAmountTracker(data.exchangeRates)) } countryData.trials.registerTrial(trial) } @@ -82,13 +83,13 @@ class TopCountriesTable( } override suspend fun process(licenseInfo: LicenseInfo) { - allSalesTracker.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) + allSalesTracker.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) val countryData = countries.getOrPut(licenseInfo.sale.customer.country.orEmptyCountry()) { - CountryData(AmountTargetCurrencyTracker(exchangeRates)) + CountryData(MonetaryAmountTracker(exchangeRates)) } countryData.salesCount += 1 - countryData.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount, licenseInfo.currency) + countryData.totalSales.add(licenseInfo.sale.date, licenseInfo.amountUSD, licenseInfo.amount) } override suspend fun createSections(): List { @@ -98,11 +99,11 @@ class TopCountriesTable( val totalTrialCount = allTrialsResult.totalTrials val rows = countries.entries - .sortedByDescending { it.value.totalSales.getTotalAmount().amount } + .sortedByDescending { it.value.totalSales.getTotalAmount() } .take(maxItems ?: Int.MAX_VALUE) .map { (country, countryData) -> val totalSales = countryData.totalSales.takeIf { countryData.salesCount > 0 } - val salesPercentage = PercentageValue.of(countryData.totalSales.getTotalAmount().amount, totalSalesAmount.amount) + val salesPercentage = PercentageValue.of(countryData.totalSales.getTotalAmount(), totalSalesAmount) val trialsResult = countryData.trials.getResult() val trialPercentage = PercentageValue.of(trialsResult.totalTrials, totalTrialCount) @@ -119,7 +120,7 @@ class TopCountriesTable( columnTrialConvertedPercentage to trialConversion, ), sortValues = mapOf( - columnSales to (totalAmount?.amount?.toLong() ?: -1L), + columnSales to (totalAmount?.sortValue() ?: -1L), columnSalesPercentage to salesPercentage.value.toLong(), columnTrialCount to trialsResult.totalTrials.toLong(), columnTrialsPercentage to trialPercentage.value.toLong(), diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountTargetCurrencyTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountTargetCurrencyTracker.kt deleted file mode 100644 index 89a2b95..0000000 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountTargetCurrencyTracker.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.data.trackers - -import dev.ja.marketplace.client.* -import dev.ja.marketplace.exchangeRate.ExchangeRates -import dev.ja.marketplace.services.Currency - -/** - * Tracks payments with currencies and converts them into the target currency. - */ -class AmountTargetCurrencyTracker(private val exchangeRates: ExchangeRates) { - private var sumUSD = Amount.ZERO - private var sumTargetCurrency = Amount.ZERO - - suspend fun add(date: YearMonthDay, amountUSD: Amount, amount: Amount, currency: Currency) { - sumUSD += amountUSD - sumTargetCurrency += when { - MarketplaceCurrencies.USD.hasCode(exchangeRates.targetCurrencyCode) -> amountUSD - else -> exchangeRates.convert(date, amount, currency.isoCode) - } - } - - fun getTotalAmountUSD(): Amount { - return sumUSD - } - - fun getTotalAmount(): AmountWithCurrency { - return sumTargetCurrency.withCurrency(exchangeRates.targetCurrencyCode) - } -} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTracker.kt deleted file mode 100644 index 9c33ea0..0000000 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTracker.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.data.trackers - -import dev.ja.marketplace.client.Amount -import dev.ja.marketplace.client.AmountWithCurrency -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.exchangeRate.ExchangeRates -import java.util.* - -/** - * Tracks amounts with currency. - */ -class AmountWithCurrencyTracker(private val exchangeRates: ExchangeRates) { - // currency code to amount - private val amounts = TreeMap() - - fun getValues(): List { - return when { - amounts.isEmpty() -> emptyList() - else -> amounts.entries.map { AmountWithCurrency(it.value, it.key) } - } - } - - suspend fun getConvertedResult(date: YearMonthDay): AmountWithCurrency { - var convertedResult = AmountWithCurrency(Amount.ZERO, exchangeRates.targetCurrencyCode) - for ((currency, amount) in amounts) { - convertedResult += exchangeRates.convert(date, amount, currency) - } - return convertedResult - } - - fun add(amount: AmountWithCurrency) { - add(amount.amount, amount.currencyCode) - } - - fun add(amount: Amount, currencyCode: String) { - amounts.merge(currencyCode, amount) { sum, new -> sum + new } - } - - operator fun plusAssign(paidAmount: AmountWithCurrency) { - add(paidAmount) - } -} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/LicenseTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/LicenseTracker.kt index ba55434..6ba187d 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/LicenseTracker.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/LicenseTracker.kt @@ -6,24 +6,19 @@ package dev.ja.marketplace.data.trackers import dev.ja.marketplace.client.YearMonthDayRange +import dev.ja.marketplace.data.LicenseId import dev.ja.marketplace.data.LicenseInfo class LicenseTracker(private val dateRange: YearMonthDayRange) { - private val segmentedLicenses = mutableMapOf>() - private val licenses = mutableSetOf() - private val licensesFree = mutableSetOf() - private val licensesPaying = mutableSetOf() + private val segmentedLicenses = mutableMapOf>() + private val licenses = mutableSetOf() + private val licensesPaying = mutableSetOf() val totalLicenseCount: Int get() { return licenses.size } - val freeLicensesCount: Int - get() { - return licensesFree.size - } - val paidLicensesCount: Int get() { return licensesPaying.size @@ -39,15 +34,15 @@ class LicenseTracker(private val dateRange: YearMonthDayRange) { fun add(segment: T, licenseInfo: LicenseInfo) { if (dateRange.end in licenseInfo.validity) { - licenses += licenseInfo + licenses += licenseInfo.id if (licenseInfo.isPaidLicense) { - licensesPaying += licenseInfo - } else { - licensesFree += licenseInfo + licensesPaying += licenseInfo.id } - segmentedLicenses.computeIfAbsent(segment) { mutableSetOf() } += licenseInfo + segmentedLicenses.computeIfAbsent(segment) { + ArrayList(500) + } += licenseInfo } } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/MonetaryAmountTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/MonetaryAmountTracker.kt new file mode 100644 index 0000000..62c7ff1 --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/MonetaryAmountTracker.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.data.trackers + +import dev.ja.marketplace.client.MarketplaceCurrencies +import dev.ja.marketplace.client.YearMonthDay +import dev.ja.marketplace.client.plus +import dev.ja.marketplace.exchangeRate.ExchangeRates +import org.javamoney.moneta.FastMoney +import org.javamoney.moneta.Money +import javax.money.MonetaryAmount + +/** + * Tracks payments with currencies and converts them into the target currency. + */ +class MonetaryAmountTracker(private val exchangeRates: ExchangeRates) { + private var sumUSD: MonetaryAmount = FastMoney.zero(MarketplaceCurrencies.USD) + private var sumTargetCurrency: MonetaryAmount = FastMoney.zero(exchangeRates.targetCurrency) + + fun add(date: YearMonthDay, amountUSD: MonetaryAmount, amount: MonetaryAmount) { + sumUSD += amountUSD + sumTargetCurrency += when (exchangeRates.targetCurrency) { + MarketplaceCurrencies.USD -> amountUSD + amount.currency -> amount + else -> exchangeRates.convert(date, amount) + } + } + + fun getTotalAmountUSD(): MonetaryAmount { + return sumUSD + } + + fun getTotalAmount(): MonetaryAmount { + return sumTargetCurrency + } +} \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/PaymentAmountTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/PaymentAmountTracker.kt index 1c82fad..e74ab1f 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/PaymentAmountTracker.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/PaymentAmountTracker.kt @@ -7,43 +7,39 @@ package dev.ja.marketplace.data.trackers import dev.ja.marketplace.client.* import dev.ja.marketplace.exchangeRate.ExchangeRates -import dev.ja.marketplace.services.Currency -import dev.ja.marketplace.util.isZero +import javax.money.MonetaryAmount /** * Tracks the payment within a given range of dates. */ -data class PaymentAmountTracker( - val filterDateRange: YearMonthDayRange, - private val exchangeRates: ExchangeRates -) { - private val total = AmountTargetCurrencyTracker(exchangeRates) - private val fees = AmountTargetCurrencyTracker(exchangeRates) +data class PaymentAmountTracker(val filterDateRange: YearMonthDayRange, private val exchangeRates: ExchangeRates) { + private val total = MonetaryAmountTracker(exchangeRates) + private val fees = MonetaryAmountTracker(exchangeRates) val isZero: Boolean get() = total.getTotalAmountUSD().isZero() - val totalAmountUSD: Amount get() = total.getTotalAmountUSD() + val totalAmountUSD: MonetaryAmount get() = total.getTotalAmountUSD() - val totalAmount: AmountWithCurrency get() = total.getTotalAmount() + val totalAmount: MonetaryAmount get() = total.getTotalAmount() - val feesAmountUSD: Amount get() = fees.getTotalAmountUSD() + val feesAmountUSD: MonetaryAmount get() = fees.getTotalAmountUSD() - val feesAmount: AmountWithCurrency get() = fees.getTotalAmount() + val feesAmount: MonetaryAmount get() = fees.getTotalAmount() - val paidAmountUSD: Amount + val paidAmountUSD: MonetaryAmount get() { return totalAmountUSD - feesAmountUSD } - val paidAmount: AmountWithCurrency + val paidAmount: MonetaryAmount get() { return totalAmount - feesAmount } - suspend fun add(paymentDate: YearMonthDay, amountUSD: Amount, amount: Amount, currency: Currency) { + suspend fun add(paymentDate: YearMonthDay, amountUSD: MonetaryAmount, amount: MonetaryAmount) { if (paymentDate in filterDateRange) { - total.add(paymentDate, amountUSD, amount, currency) - fees.add(paymentDate, Marketplace.feeAmount(paymentDate, amountUSD), Marketplace.feeAmount(paymentDate, amount), currency) + total.add(paymentDate, amountUSD, amount) + fees.add(paymentDate, Marketplace.feeAmount(paymentDate, amountUSD), Marketplace.feeAmount(paymentDate, amount)) } } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/RecurringRevenueTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/RecurringRevenueTracker.kt index 17cc324..2d5cdbd 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/RecurringRevenueTracker.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/trackers/RecurringRevenueTracker.kt @@ -5,7 +5,10 @@ package dev.ja.marketplace.data.trackers -import dev.ja.marketplace.client.* +import dev.ja.marketplace.client.LicensePeriod +import dev.ja.marketplace.client.YearMonthDay +import dev.ja.marketplace.client.YearMonthDayRange +import dev.ja.marketplace.client.times import dev.ja.marketplace.data.ContinuityDiscount import dev.ja.marketplace.data.LicenseId import dev.ja.marketplace.data.LicenseInfo @@ -47,11 +50,10 @@ abstract class RecurringRevenueTracker( } fun getResult(): RecurringRevenue { - val resultAmounts = AmountWithCurrencyTracker(exchangeRates) + val resultAmounts = MonetaryAmountTracker(exchangeRates) for ((_, license) in latestSales) { val basePrice = pluginPricing.getBasePrice( - dateRange.end, license.sale.customer, license.sale.licensePeriod, nextContinuityDiscount(license) @@ -60,11 +62,9 @@ abstract class RecurringRevenueTracker( "Unable to find base price for country ${license.sale.customer.country}" } - val basePriceFactor = basePriceFactor(license.sale.licensePeriod) - val otherDiscountsFactor = otherDiscountsFactor(license) - val factors = basePriceFactor * otherDiscountsFactor.toBigDecimal() + val factors = basePriceFactor(license.sale.licensePeriod) * otherDiscountsFactor(license).toBigDecimal() - resultAmounts += Marketplace.paidAmount(license.validity.end, basePrice!! * factors) + resultAmounts.add(license.validity.end, license.amountUSD * factors, license.amount * factors) } return RecurringRevenue(dateRange, resultAmounts) @@ -84,13 +84,19 @@ abstract class RecurringRevenueTracker( } private fun otherDiscountsFactor(licenseInfo: LicenseInfo): Double { - val otherPercent = licenseInfo.saleLineItem.discountDescriptions - .filterNot(PluginSaleItemDiscount::isContinuityDiscount) - .mapNotNull { it.percent } + val discounts = licenseInfo.saleLineItem.discountDescriptions + if (discounts.isEmpty()) { + return 0.0 + } var factor = 1.0 - for (percent in otherPercent) { - factor *= 1.0 - percent / 100.0 + for (discount in discounts) { + if (!discount.isContinuityDiscount) { + val percent = discount.percent + if (percent != null) { + factor *= 1.0 - percent / 100.0 + } + } } return factor } @@ -138,16 +144,16 @@ class AnnualRecurringRevenueTracker( data class RecurringRevenue( val dateRange: YearMonthDayRange, - val amounts: AmountWithCurrencyTracker + val amounts: MonetaryAmountTracker ) { fun renderTooltip(): String { return buildString { - for (value in amounts.getValues()) { + /*for (value in amounts.getValues()) { append(value.amount.setScale(2, RoundingMode.HALF_UP)) append(" ") append(value.currencyCode) append("\n") - } + }*/ } } } \ No newline at end of file diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/types.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/types.kt index 6965acc..cd88f32 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/types.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/types.kt @@ -13,12 +13,30 @@ import dev.ja.marketplace.client.YearMonthDay import dev.ja.marketplace.util.isZero import java.math.BigDecimal import java.math.RoundingMode +import javax.money.MonetaryAmount data class PercentageValue(val value: BigDecimal) { companion object { - val ONE_HUNDRED = PercentageValue(BigDecimal(100.0)) + private val ONE_HUNDRED_DECIMAL = BigDecimal.valueOf(100) + + val ONE_HUNDRED = PercentageValue(ONE_HUNDRED_DECIMAL) + + // used by render.kte + @Suppress("MemberVisibilityCanBePrivate") val ZERO = PercentageValue(BigDecimal(0.0)) + fun of(first: MonetaryAmount, second: MonetaryAmount): PercentageValue { + assert(first.currency == second.currency) + + if (first.isZero || second.isZero) { + return ZERO + } + + val firstValue = first.number.numberValue(BigDecimal::class.java) + val secondValue = second.number.numberValue(BigDecimal::class.java) + return PercentageValue(firstValue.divide(secondValue, 10, RoundingMode.HALF_UP) * ONE_HUNDRED_DECIMAL) + } + fun of(first: BigDecimal, second: BigDecimal): PercentageValue { if (first.isZero() || second.isZero()) { return ZERO diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/yearSummary/YearlySummaryTable.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/yearSummary/YearlySummaryTable.kt index 221c01b..db758c4 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/yearSummary/YearlySummaryTable.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/yearSummary/YearlySummaryTable.kt @@ -18,6 +18,7 @@ class YearlySummaryTable : SimpleDataTable("Years", "years", "table-column-wide" private val allTrialsTracker: TrialTracker = SimpleTrialTracker() private val data = TreeMap(Comparator.reverseOrder()) + private lateinit var totalSales: PaymentAmountTracker private data class YearSummary( val sales: PaymentAmountTracker, @@ -29,12 +30,13 @@ class YearlySummaryTable : SimpleDataTable("Years", "years", "table-column-wide" super.init(data) this.downloads = data.downloadsMonthly + this.totalSales = PaymentAmountTracker(YearMonthDayRange.MAX, data.exchangeRates) val now = YearMonthDay.now() for (year in Marketplace.Birthday.year..now.year) { val yearRange = YearMonthDayRange.ofYear(year) this.data[year] = YearSummary( - PaymentAmountTracker(yearRange, exchangeRates), + PaymentAmountTracker(yearRange, data.exchangeRates), SimpleTrialTracker { it.date in yearRange }, AnnualRecurringRevenueTracker(yearRange, data.continuityDiscountTracker!!, data.pluginPricing!!, data.exchangeRates) ) @@ -51,8 +53,10 @@ class YearlySummaryTable : SimpleDataTable("Years", "years", "table-column-wide" override suspend fun process(sale: PluginSale) { allTrialsTracker.processSale(sale) + totalSales.add(sale.date, sale.amountUSD, sale.amount) + val yearData = data[sale.date.year]!! - yearData.sales.add(sale.date, sale.amountUSD, sale.amount, sale.currency) + yearData.sales.add(sale.date, sale.amountUSD, sale.amount) yearData.trials.processSale(sale) } @@ -102,7 +106,7 @@ class YearlySummaryTable : SimpleDataTable("Years", "years", "table-column-wide" columnSalesTotal to yearData.sales.totalAmount, columnSalesFees to yearData.sales.feesAmount, columnSalesPaid to yearData.sales.paidAmount, - columnARR to (arrResult?.amounts?.getConvertedResult(lastOfYear) ?: NoValue), + columnARR to (arrResult?.amounts?.getTotalAmount() ?: NoValue), columnDownloads to downloads .filter { it.firstOfMonth.year == year } .sumOf(MonthlyDownload::downloads) @@ -126,12 +130,9 @@ class YearlySummaryTable : SimpleDataTable("Years", "years", "table-column-wide" rows = rows, footer = SimpleTableSection( SimpleDateTableRow( - columnSalesTotal to rows.sumOf { (it.values[columnSalesTotal] as AmountWithCurrency).amount } - .withCurrency(exchangeRates.targetCurrencyCode), - columnSalesFees to rows.sumOf { (it.values[columnSalesFees] as AmountWithCurrency).amount } - .withCurrency(exchangeRates.targetCurrencyCode), - columnSalesPaid to rows.sumOf { (it.values[columnSalesPaid] as AmountWithCurrency).amount } - .withCurrency(exchangeRates.targetCurrencyCode), + columnSalesTotal to totalSales.totalAmount, + columnSalesFees to totalSales.feesAmount, + columnSalesPaid to totalSales.paidAmount, columnDownloads to downloads.sumOf { it.downloads.toBigInteger() }, columnTrials to allTrialsResult.totalTrials.toBigInteger(), columnTrialsConverted to allTrialsResult.convertedTrialsPercentage, diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/util/money.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/util/money.kt new file mode 100644 index 0000000..94c065b --- /dev/null +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/util/money.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024 Joachim Ansorg. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package dev.ja.marketplace.util + +import javax.money.MonetaryAmount + +fun MonetaryAmount.sortValue(): Long { + return this.number.toLong() +} \ No newline at end of file diff --git a/marketplace-data/src/test/kotlin/dev/ja/SalesGenerator.kt b/marketplace-data/src/test/kotlin/dev/ja/SalesGenerator.kt index 5b5ac4d..1e587fd 100644 --- a/marketplace-data/src/test/kotlin/dev/ja/SalesGenerator.kt +++ b/marketplace-data/src/test/kotlin/dev/ja/SalesGenerator.kt @@ -7,8 +7,10 @@ package dev.ja import dev.ja.marketplace.TestCustomers import dev.ja.marketplace.client.* -import dev.ja.marketplace.services.Currency +import org.javamoney.moneta.FastMoney import java.util.concurrent.atomic.AtomicInteger +import javax.money.CurrencyUnit +import javax.money.MonetaryAmount import kotlin.random.Random object SalesGenerator { @@ -17,21 +19,20 @@ object SalesGenerator { saleDate: YearMonthDay? = null, validity: YearMonthDayRange? = null, customer: CustomerInfo? = null, - amount: Amount? = null, - currency: Currency = MarketplaceCurrencies.USD, + amount: MonetaryAmount? = null, + currency: CurrencyUnit = MarketplaceCurrencies.USD, saleType: PluginSaleItemType = PluginSaleItemType.New, ): PluginSale { val start = saleDate ?: nextDate() val usedValidity = validity ?: if (type == LicensePeriod.Annual) start.rangeTo(start.add(1, 0, -1)) else start.rangeTo(start.add(0, 1, -1)) - val usedAmount = amount ?: randomAmount() + val usedAmount = amount ?: randomAmount(currency) return PluginSale( nextRef(), start, usedAmount, usedAmount, - currency, type, customer ?: TestCustomers.PersonDummy, null, @@ -57,7 +58,7 @@ object SalesGenerator { return date } - private fun randomAmount(): Amount { - return Random.nextInt(50).toBigDecimal() + private fun randomAmount(currency: CurrencyUnit): MonetaryAmount { + return FastMoney.of(Random.nextInt(50).toBigDecimal(), currency) } } \ No newline at end of file diff --git a/marketplace-data/src/test/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessorTest.kt b/marketplace-data/src/test/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessorTest.kt deleted file mode 100644 index 0a472ac..0000000 --- a/marketplace-data/src/test/kotlin/dev/ja/marketplace/churn/SimpleChurnProcessorTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2023 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.churn - -import dev.ja.marketplace.client.LicensePeriod -import dev.ja.marketplace.client.YearMonthDay -import dev.ja.marketplace.client.YearMonthDayRange -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class SimpleChurnProcessorTest { - @Test - fun noChurn() { - val churnDate = YearMonthDay(2023, 5, 31) - val processor = SimpleChurnProcessor(churnDate.add(0, -1, 0), churnDate, 7) - processor.init() - - processor.processValue(1, "a1", YearMonthDayRange(YearMonthDay(2023, 4, 10), YearMonthDay(2023, 5, 9)), true) - processor.processValue(1, "a2", YearMonthDayRange(YearMonthDay(2023, 5, 10), YearMonthDay(2023, 6, 10)), true) - - val result = processor.getResult(LicensePeriod.Annual) - assertEquals(0, result.churnedItemCount) - assertEquals(1, result.activeItemCount) - assertEquals(0.0, result.churnRate) - } - - @Test - fun halfChurn() { - val churnDate = YearMonthDay(2023, 5, 31) - val processor = SimpleChurnProcessor(churnDate.add(0, -1, 0), churnDate, 7) - - processor.init() - processor.processValue(1, "churned1", YearMonthDay(2023, 3, 10).rangeTo(YearMonthDay(2023, 4, 9)), true) - processor.processValue( - 1, - "churned2", - YearMonthDay(2023, 4, 10).rangeTo(YearMonthDay(2023, 4, 30)), - true - ) - processor.processValue(2, "a1", YearMonthDay(2023, 4, 10).rangeTo(YearMonthDay(2023, 5, 9)), true) - processor.processValue(2, "a2", YearMonthDay(2023, 5, 10).rangeTo(YearMonthDay(2023, 6, 10)), true) - - val result = processor.getResult(LicensePeriod.Annual) - assertEquals(1, result.churnedItemCount) - assertEquals(1, result.activeItemCount) - assertEquals(0.5, result.churnRate) - } - - @Test - fun outsideGracePeriod() { - val churnDate = YearMonthDay(2023, 5, 31) - val processor = SimpleChurnProcessor(churnDate.add(0, -1, 0), churnDate, 7) - processor.init() - - // valid in previous period - processor.processValue(1, "churned1", YearMonthDay(2023, 4, 15).rangeTo(YearMonthDay(2023, 5, 15)), true) - processor.processValue(2, "churned2", YearMonthDay(2022, 5, 30).rangeTo(YearMonthDay(2023, 5, 30)), true) - - // new license outside current period - processor.processValue(1, "churned1", YearMonthDay(2023, 6, 8).rangeTo(YearMonthDay(2023, 8, 8)), true) - processor.processValue(2, "churned2", YearMonthDay(2023, 5, 1).rangeTo(YearMonthDay(2023, 5, 30)), true) - - val result = processor.getResult(LicensePeriod.Annual) - assertEquals(2, result.churnedItemCount) - assertEquals(0, result.activeItemCount) - assertEquals(1.0, result.churnRate) - } -} \ No newline at end of file diff --git a/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/SplitAmountTest.kt b/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/SplitAmountTest.kt index 2aa6310..91d437c 100644 --- a/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/SplitAmountTest.kt +++ b/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/SplitAmountTest.kt @@ -1,53 +1,52 @@ /* - * Copyright (c) 2023 Joachim Ansorg. + * Copyright (c) 2023-2024 Joachim Ansorg. * SPDX-License-Identifier: AGPL-3.0-or-later */ package dev.ja.marketplace.data -import dev.ja.marketplace.client.Amount +import org.javamoney.moneta.FastMoney import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -import java.math.RoundingMode class SplitAmountTest { @Test fun splitAmount() { - val amountEUR = Amount(1.1) - val amountUSD = Amount(1.0) + val amountEUR = FastMoney.of(1.1, "EUR") + val amountUSD = FastMoney.of(1.0, "USD") SplitAmount.split(amountEUR, amountUSD, listOf("a", "b", "c")) { splitEur, splitUsd, item -> if (item == "c") { - assertEquals(Amount(0.38).setScale(2, RoundingMode.HALF_UP), splitEur) - assertEquals(Amount(0.34).setScale(2, RoundingMode.HALF_UP), splitUsd) + assertEquals(FastMoney.of(0.38, "EUR"), splitEur) + assertEquals(FastMoney.of(0.34, "USD"), splitUsd) } else { - assertEquals(Amount(0.36).setScale(2, RoundingMode.HALF_UP), splitEur) - assertEquals(Amount(0.33).setScale(2, RoundingMode.HALF_UP), splitUsd) + assertEquals(FastMoney.of(0.36, "EUR"), splitEur) + assertEquals(FastMoney.of(0.33, "USD"), splitUsd) } } } @Test fun splitAmount2() { - val amountEUR = Amount(110) - val amountUSD = Amount(100) + val amountEUR = FastMoney.of(110, "EUR") + val amountUSD = FastMoney.of(100, "USD") SplitAmount.split(amountEUR, amountUSD, listOf("a", "b", "c")) { splitEur, splitUsd, item -> if (item == "c") { - assertEquals(Amount(36.68).setScale(2, RoundingMode.HALF_UP), splitEur) - assertEquals(Amount(33.34).setScale(2, RoundingMode.HALF_UP), splitUsd) + assertEquals(FastMoney.of(36.68, "EUR"), splitEur) + assertEquals(FastMoney.of(33.34, "USD"), splitUsd) } else { - assertEquals(Amount(36.66).setScale(2, RoundingMode.HALF_UP), splitEur) - assertEquals(Amount(33.33).setScale(2, RoundingMode.HALF_UP), splitUsd) + assertEquals(FastMoney.of(36.66, "EUR"), splitEur) + assertEquals(FastMoney.of(33.33, "USD"), splitUsd) } } } @Test fun splitNoRemainder() { - val amountEUR = Amount(210) - val amountUSD = Amount(150) + val amountEUR = FastMoney.of(210, "EUR") + val amountUSD = FastMoney.of(150, "USD") SplitAmount.split(amountEUR, amountUSD, listOf("a", "b", "c")) { splitEur, splitUsd, _ -> - assertEquals(Amount(70).setScale(2, RoundingMode.HALF_UP), splitEur) - assertEquals(Amount(50).setScale(2, RoundingMode.HALF_UP), splitUsd) + assertEquals(FastMoney.of(70, "EUR"), splitEur) + assertEquals(FastMoney.of(50, "USD"), splitUsd) } } } \ No newline at end of file diff --git a/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTrackerTest.kt b/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTrackerTest.kt deleted file mode 100644 index 35b27ec..0000000 --- a/marketplace-data/src/test/kotlin/dev/ja/marketplace/data/trackers/AmountWithCurrencyTrackerTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2024 Joachim Ansorg. - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package dev.ja.marketplace.data.trackers - -import dev.ja.marketplace.client.Amount -import dev.ja.marketplace.client.AmountWithCurrency -import dev.ja.marketplace.client.MarketplaceCurrencies -import dev.ja.marketplace.exchangeRate.EmptyExchangeRates -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test - -class AmountWithCurrencyTrackerTest { - @Test - fun tracking() { - val amounts = AmountWithCurrencyTracker(EmptyExchangeRates) - amounts.add(Amount(10), MarketplaceCurrencies.EUR.isoCode) - amounts.add(Amount(100), MarketplaceCurrencies.USD.isoCode) - amounts.add(Amount(1000), MarketplaceCurrencies.JPY.isoCode) - - Assertions.assertEquals( - setOf( - AmountWithCurrency(Amount(10), MarketplaceCurrencies.EUR), - AmountWithCurrency(Amount(100), MarketplaceCurrencies.USD), - AmountWithCurrency(Amount(1000), MarketplaceCurrencies.JPY), - ), amounts.getValues().toSet() - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/dev/ja/marketplace/Application.kt b/src/main/kotlin/dev/ja/marketplace/Application.kt index 402b55b..94da78b 100644 --- a/src/main/kotlin/dev/ja/marketplace/Application.kt +++ b/src/main/kotlin/dev/ja/marketplace/Application.kt @@ -23,7 +23,6 @@ import com.github.ajalt.clikt.parameters.types.path import dev.ja.marketplace.client.CachingMarketplaceClient import dev.ja.marketplace.client.ClientLogLevel import dev.ja.marketplace.client.KtorMarketplaceClient -import dev.ja.marketplace.exchangeRate.FrankfurterExchangeRateProvider import dev.ja.marketplace.services.KtorJetBrainsServiceClient import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json @@ -64,7 +63,11 @@ class Application(version: String) : CliktCommand( private val displayCurrency: String? by option("-c", "--currency", envvar = "MARKETPLACE_DISPLAY_CURRENCY") .help("Currency for the displayed monetary amounts.") - private val logging: ClientLogLevel by option("-d", "--debug", envvar = "MARKETPLACE_LOG_LEVEL").enum(key = { it.name.lowercase() }) + private val logging: ClientLogLevel by option( + "-d", + "--debug", + envvar = "MARKETPLACE_LOG_LEVEL" + ).enum(key = { it.name.lowercase() }) .default(ClientLogLevel.None) .help("The log level used for the server and the API requests to the marketplace") @@ -81,7 +84,6 @@ class Application(version: String) : CliktCommand( val server = MarketplaceStatsServer( CachingMarketplaceClient(KtorMarketplaceClient(apiKey = apiKey, logLevel = logging)), KtorJetBrainsServiceClient(logLevel = logging), - FrankfurterExchangeRateProvider(frankfurterApiUrl, logLevel = logging), serverHostname, serverPort, ServerConfiguration(displayCurrencyCode) diff --git a/src/main/kotlin/dev/ja/marketplace/MarketplaceStatsServer.kt b/src/main/kotlin/dev/ja/marketplace/MarketplaceStatsServer.kt index 2c1798f..c268643 100644 --- a/src/main/kotlin/dev/ja/marketplace/MarketplaceStatsServer.kt +++ b/src/main/kotlin/dev/ja/marketplace/MarketplaceStatsServer.kt @@ -5,10 +5,9 @@ package dev.ja.marketplace -import dev.ja.marketplace.churn.MarketplaceChurnProcessor +import dev.ja.marketplace.churn.LicenseChurnProcessor import dev.ja.marketplace.client.* import dev.ja.marketplace.data.DataTable -import dev.ja.marketplace.data.LicenseInfo import dev.ja.marketplace.data.MarketplaceDataTableFactory import dev.ja.marketplace.data.customerType.CustomerTypeFactory import dev.ja.marketplace.data.customers.ActiveCustomerTableFactory @@ -28,7 +27,6 @@ import dev.ja.marketplace.data.topTrialCountries.TopTrialCountriesFactory import dev.ja.marketplace.data.trials.TrialsTable import dev.ja.marketplace.data.trials.TrialsTableFactory import dev.ja.marketplace.data.yearSummary.YearlySummaryFactory -import dev.ja.marketplace.exchangeRate.ExchangeRateProvider import dev.ja.marketplace.exchangeRate.ExchangeRates import dev.ja.marketplace.services.Countries import dev.ja.marketplace.services.KtorJetBrainsServiceClient @@ -53,7 +51,6 @@ import kotlinx.coroutines.coroutineScope class MarketplaceStatsServer( private val client: MarketplaceClient, private val servicesClient: KtorJetBrainsServiceClient, - private val exchangeRateProvider: ExchangeRateProvider, private val host: String = "0.0.0.0", private val port: Int = 8080, private val serverConfiguration: ServerConfiguration, @@ -314,12 +311,7 @@ class MarketplaceStatsServer( countries = countriesAsync.await() allPlugins = allPluginsAsync.await() - exchangeRates = ExchangeRates( - exchangeRateProvider, - Marketplace.Birthday, - serverConfiguration.userDisplayCurrencyCode, - MarketplaceCurrencies - ) + exchangeRates = ExchangeRates(serverConfiguration.userDisplayCurrencyCode) println("Launching web server: http://$host:$port/") httpServer.start(true) @@ -421,12 +413,11 @@ class MarketplaceStatsServer( ) { val data = loader.load() - val churnProcessor = MarketplaceChurnProcessor(lastActiveMarker, activeMarker, ::HashSet) + val churnProcessor = LicenseChurnProcessor(lastActiveMarker, activeMarker) churnProcessor.init() data.licenses!!.forEach { churnProcessor.processValue( - it.id, it, it.validity, it.sale.licensePeriod == period && it.isPaidLicense, diff --git a/src/main/kotlin/dev/ja/marketplace/PluginDataLoader.kt b/src/main/kotlin/dev/ja/marketplace/PluginDataLoader.kt index feca29e..3633e96 100644 --- a/src/main/kotlin/dev/ja/marketplace/PluginDataLoader.kt +++ b/src/main/kotlin/dev/ja/marketplace/PluginDataLoader.kt @@ -36,7 +36,7 @@ class PluginDataLoader( plugin.isPaidOrFreemium -> async(Dispatchers.IO) { client.salesInfo(plugin.id) } else -> null } - val licenseInfo = when { + val licenseInfos = when { plugin.isPaidOrFreemium && sales != null -> async(Dispatchers.IO) { LicenseInfo.create(sales.await()) } else -> null } @@ -65,7 +65,7 @@ class PluginDataLoader( downloadsDaily.await(), downloadsProduct.await(), sales?.await(), - licenseInfo?.await(), + licenseInfos?.await(), trials?.await(), marketplacePluginInfo?.await(), pricingInfo?.await(), diff --git a/src/main/resources/templates/render.kte b/src/main/resources/templates/render.kte index d9d5caa..358c4cc 100644 --- a/src/main/resources/templates/render.kte +++ b/src/main/resources/templates/render.kte @@ -1,11 +1,12 @@ @import gg.jte.support.ForSupport @import java.math.BigDecimal @import java.math.BigInteger -@import dev.ja.marketplace.client.AmountWithCurrency @import dev.ja.marketplace.data.LinkedChurnRate @import dev.ja.marketplace.data.LinkedCustomer @import dev.ja.marketplace.data.LinkedLicense @import dev.ja.marketplace.data.PercentageValue +@import dev.ja.marketplace.data.format.Formatters +@import javax.money.MonetaryAmount @param value: Any? @@ -17,8 +18,8 @@ @for(i in ForSupport.of(value)) @template.render(i.get())@if(!i.isLast)
@endif @endfor -@elseif(value is AmountWithCurrency) - @template.render(value.amount) @template.render(value.currencyCode) +@elseif(value is MonetaryAmount) + ${Formatters.MonetaryAmount.format(value)} @elseif(value is PercentageValue) @if(value == PercentageValue.ZERO) —