From 36179f4ea5aa5a78927bd5745f7daf2c985b0473 Mon Sep 17 00:00:00 2001 From: Joachim Ansorg Date: Thu, 31 Aug 2023 16:08:40 +0200 Subject: [PATCH] feat: show churn rate of paid licenses, exclude churn of free licenses --- .../data/overview/CustomerTracker.kt | 25 ++- .../data/overview/OverviewTable.kt | 149 ++++++++++++++++-- 2 files changed, 157 insertions(+), 17 deletions(-) diff --git a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/CustomerTracker.kt b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/CustomerTracker.kt index 4848f16..3b8fa20 100644 --- a/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/CustomerTracker.kt +++ b/marketplace-data/src/main/kotlin/dev/ja/marketplace/data/overview/CustomerTracker.kt @@ -12,20 +12,41 @@ import dev.ja.marketplace.data.LicenseInfo class CustomerTracker(private val dateRange: YearMonthDayRange) { private val segmentedCustomers = mutableMapOf>() private val customers = mutableSetOf() + private val customersFree = mutableSetOf() + private val customersPaying = mutableSetOf() val totalCustomerCount: Int get() { return customers.size } + val freeCustomerCount: Int + get() { + return customersFree.size + } + + val payingCustomerCount: Int + get() { + return customersPaying.size + } + fun segmentCustomerCount(segment: T): Int { return segmentedCustomers[segment]?.size ?: 0 } fun add(segment: T, licenseInfo: LicenseInfo) { if (dateRange.end in licenseInfo.validity) { - customers += licenseInfo.sale.customer - segmentedCustomers.computeIfAbsent(segment) { mutableSetOf() } += licenseInfo.sale.customer + val customer = licenseInfo.sale.customer + + customers += customer + + if (licenseInfo.saleLineItem.isFreeLicense) { + customersFree += customer + } else { + customersPaying += customer + } + + segmentedCustomers.computeIfAbsent(segment) { mutableSetOf() } += customer } } } \ No newline at end of file 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 d150201..36f581f 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 @@ -10,19 +10,47 @@ import dev.ja.marketplace.churn.SimpleChurnProcessor import dev.ja.marketplace.client.* import dev.ja.marketplace.client.Currency import dev.ja.marketplace.data.* +import dev.ja.marketplace.data.overview.OverviewTable.CustomerSegment.* import java.math.BigDecimal import java.util.* -class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overview", "overview", "table-striped tables-row"), +class OverviewTable(private val graceTimeDays: Int = 7) : + SimpleDataTable("Overview", "overview", "table-striped tables-row"), MarketplaceDataSink { + private enum class CustomerSegment { + AnnualFree, + AnnualPaying, + MonthlyFree, + MonthlyPaying; + + companion object { + fun of(licenseInfo: LicenseInfo): CustomerSegment { + val isFreeLicense = licenseInfo.saleLineItem.isFreeLicense + return when { + isFreeLicense -> when (licenseInfo.sale.licensePeriod) { + LicensePeriod.Annual -> AnnualFree + LicensePeriod.Monthly -> MonthlyFree + } + + else -> when (licenseInfo.sale.licensePeriod) { + LicensePeriod.Annual -> AnnualPaying + LicensePeriod.Monthly -> MonthlyPaying + } + } + } + } + } + private data class MonthData( val year: Int, val month: Int, - val customers: CustomerTracker, + val customers: CustomerTracker, val amounts: PaymentAmountTracker, val churnAnnualLicenses: ChurnProcessor, + val churnAnnualPaidLicenses: ChurnProcessor, val churnMonthlyLicenses: ChurnProcessor, + val churnMonthlyPaidLicenses: ChurnProcessor, val downloads: Long, ) { val isEmpty: Boolean @@ -34,7 +62,9 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv private data class YearData( val year: Int, val churnAnnualLicenses: ChurnProcessor, + val churnAnnualPaidLicenses: ChurnProcessor, val churnMonthlyLicenses: ChurnProcessor, + val churnMonthlyPaidLicenses: ChurnProcessor, val months: Map ) { val isEmpty: Boolean @@ -56,12 +86,21 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv private val columnActiveCustomers = DataTableColumn( "customer-count", "Cust.", "num", tooltip = "Customers at the end of month" ) + private val columnActiveCustomersPaying = DataTableColumn( + "customer-count-paying", "Paying Cust.", "num", tooltip = "Paying customers at the end of month" + ) private val columnAnnualChurn = DataTableColumn( "churn-annual", "Annual", "num num-percentage", tooltip = "Churn of annual licenses" ) + private val columnAnnualChurnPaid = DataTableColumn( + "churn-annual-paid", "Annual (paid)", "num num-percentage", tooltip = "Churn of paid annual licenses" + ) private val columnMonthlyChurn = DataTableColumn( "churn-monthly", "Monthly", "num num-percentage", tooltip = "Churn of monthly licenses" ) + private val columnMonthlyChurnPaid = DataTableColumn( + "churn-monthly-paid", "Monthly (paid)", "num num-percentage", tooltip = "Churn of paid monthly licenses" + ) private val columnTrials = DataTableColumn( "trials", "Trials", "num ", tooltip = "Number of new trials at the end of the month" ) @@ -75,8 +114,11 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv columnAmountFeesUSD, columnAmountPaidUSD, columnActiveCustomers, - columnAnnualChurn, - columnMonthlyChurn, + columnActiveCustomersPaying, +// columnAnnualChurn, + columnAnnualChurnPaid, +// columnMonthlyChurn, + columnMonthlyChurnPaid, columnDownloads, columnTrials, ) @@ -97,23 +139,33 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv val currentMonth = YearMonthDayRange.ofMonth(year, month) val churnDate = currentMonth.end val activeDate = churnDate.add(0, -1, 0) - val churnAnnualCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) - churnAnnualCustomers.init() - val churnMonthlyCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) - churnMonthlyCustomers.init() + val churnAnnual = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnAnnual.init() + + val churnAnnualPaying = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnAnnualPaying.init() + + val churnMonthly = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnMonthly.init() + + val churnMonthlyPaying = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnMonthlyPaying.init() val activeCustomerRange = when { YearMonthDay.now() in currentMonth -> currentMonth.copy(end = YearMonthDay.now()) else -> currentMonth } + MonthData( year, month, CustomerTracker(activeCustomerRange), PaymentAmountTracker(currentMonth), - churnAnnualCustomers, - churnMonthlyCustomers, + churnAnnual, + churnAnnualPaying, + churnMonthly, + churnMonthlyPaying, downloadsMonthly .firstOrNull { it.firstOfMonth.year == year && it.firstOfMonth.month == month } ?.downloads @@ -131,10 +183,23 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv val churnAnnualCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) churnAnnualCustomers.init() + val churnAnnualPayingCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnAnnualPayingCustomers.init() + val churnMonthlyCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) churnMonthlyCustomers.init() - years[year] = YearData(year, churnAnnualCustomers, churnMonthlyCustomers, months) + val churnMonthlyPayingCustomers = SimpleChurnProcessor(activeDate, churnDate, graceTimeDays) + churnMonthlyPayingCustomers.init() + + years[year] = YearData( + year, + churnAnnualCustomers, + churnAnnualPayingCustomers, + churnMonthlyCustomers, + churnMonthlyPayingCustomers, + months + ) } } @@ -148,21 +213,36 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv val licensePeriod = licenseInfo.sale.licensePeriod years.values.forEach { year -> + val isPaidLicense = licenseInfo.amountUSD != Amount.ZERO && !licenseInfo.saleLineItem.isFreeLicense + year.churnAnnualLicenses.processValue( customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Annual ) + year.churnAnnualPaidLicenses.processValue( + customer.code, + customer, + licenseInfo.validity, + licensePeriod == LicensePeriod.Annual && isPaidLicense + ) + year.churnMonthlyLicenses.processValue( customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly ) + year.churnMonthlyPaidLicenses.processValue( + customer.code, + customer, + licenseInfo.validity, + licensePeriod == LicensePeriod.Monthly && isPaidLicense + ) year.months.values.forEach { month -> - month.customers.add(licensePeriod, licenseInfo) + month.customers.add(CustomerSegment.of(licenseInfo), licenseInfo) month.churnAnnualLicenses.processValue( customer.code, @@ -170,12 +250,25 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv licenseInfo.validity, licensePeriod == LicensePeriod.Annual ) + month.churnAnnualPaidLicenses.processValue( + customer.code, + customer, + licenseInfo.validity, + licensePeriod == LicensePeriod.Annual && isPaidLicense + ) + month.churnMonthlyLicenses.processValue( customer.code, customer, licenseInfo.validity, licensePeriod == LicensePeriod.Monthly ) + month.churnMonthlyPaidLicenses.processValue( + customer.code, + customer, + licenseInfo.validity, + licensePeriod == LicensePeriod.Monthly && isPaidLicense + ) } } } @@ -192,17 +285,26 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv val annualChurn = monthData.churnAnnualLicenses.getResult() val annualChurnRate = annualChurn.renderedChurnRate?.takeIf { !isCurrentMonth } + val annualChurnPaid = monthData.churnAnnualPaidLicenses.getResult() + val annualChurnRatePaid = annualChurnPaid.renderedChurnRate?.takeIf { !isCurrentMonth } + val monthlyChurn = monthData.churnMonthlyLicenses.getResult() val monthlyChurnRate = monthlyChurn.renderedChurnRate?.takeIf { !isCurrentMonth } + val monthlyChurnPaid = monthData.churnMonthlyPaidLicenses.getResult() + val monthlyChurnRatePaid = monthlyChurnPaid.renderedChurnRate?.takeIf { !isCurrentMonth } + val cssClass = when { isCurrentMonth -> "today" else -> null } - val annualCustomers = monthData.customers.segmentCustomerCount(LicensePeriod.Annual) - val monthlyCustomers = monthData.customers.segmentCustomerCount(LicensePeriod.Monthly) + 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 trialCount = trialData.count { it.date.year == year && it.date.month == month } val downloadCount = monthData.downloads @@ -211,25 +313,38 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv values = mapOf( columnYearMonth to String.format("%02d-%02d", year, month), columnActiveCustomers to totalCustomers.toBigInteger(), + columnActiveCustomersPaying to totalCustomersPaying.toBigInteger(), columnAmountTotalUSD to monthData.amounts.totalAmountUSD.withCurrency(Currency.USD), columnAmountFeesUSD to monthData.amounts.feesAmountUSD.withCurrency(Currency.USD), columnAmountPaidUSD to monthData.amounts.paidAmountUSD.withCurrency(Currency.USD), columnAnnualChurn to annualChurnRate, + columnAnnualChurnPaid to annualChurnRatePaid, columnMonthlyChurn to monthlyChurnRate, + columnMonthlyChurnPaid to monthlyChurnRatePaid, columnTrials to (trialCount.takeIf { it > 0 }?.toBigInteger() ?: "—"), columnDownloads to (downloadCount.takeIf { it > 0 }?.toBigInteger() ?: "—"), ), tooltips = mapOf( - columnActiveCustomers to "$annualCustomers annual, $monthlyCustomers monthly", + columnActiveCustomers to "$annualCustomersPaying annual (paying)" + + "\n$annualCustomersFree annual (free)" + + "\n$monthlyCustomersPaying monthly (paying)", + columnActiveCustomersPaying to "$annualCustomersPaying annual" + + "\n$monthlyCustomersPaying monthly", columnAnnualChurn to annualChurn.churnRateTooltip, + columnAnnualChurnPaid to annualChurnPaid.churnRateTooltip, columnMonthlyChurn to monthlyChurn.churnRateTooltip, + columnMonthlyChurnPaid to monthlyChurnPaid.churnRateTooltip, ), cssClass = cssClass ) } val yearAnnualChurnResult = yearData.churnAnnualLicenses.getResult() + val yearAnnualChurnResultPaid = yearData.churnAnnualPaidLicenses.getResult() + val yearMonthlyChurnResult = yearData.churnMonthlyLicenses.getResult() + val yearMonthlyChurnResultPaid = yearData.churnMonthlyPaidLicenses.getResult() + SimpleTableSection( rows, "$year", @@ -237,13 +352,17 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv SimpleDateTableRow( values = mapOf( columnAnnualChurn to yearAnnualChurnResult.renderedChurnRate, + columnAnnualChurnPaid to yearAnnualChurnResultPaid.renderedChurnRate, columnMonthlyChurn to yearMonthlyChurnResult.renderedChurnRate, + columnMonthlyChurnPaid to yearMonthlyChurnResultPaid.renderedChurnRate, columnDownloads to yearData.months.values.sumOf { it.downloads }.toBigInteger(), columnTrials to trialData.count { it.date.year == year }.toBigInteger(), ), tooltips = mapOf( columnAnnualChurn to yearAnnualChurnResult.churnRateTooltip, + columnAnnualChurnPaid to yearAnnualChurnResultPaid.churnRateTooltip, columnMonthlyChurn to yearMonthlyChurnResult.churnRateTooltip, + columnMonthlyChurnPaid to yearMonthlyChurnResultPaid.churnRateTooltip, ) ) )