Skip to content

Commit

Permalink
feat: show churn rate of paid licenses, exclude churn of free licenses
Browse files Browse the repository at this point in the history
  • Loading branch information
jansorg committed Aug 31, 2023
1 parent c60f36a commit 36179f4
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,41 @@ import dev.ja.marketplace.data.LicenseInfo
class CustomerTracker<T>(private val dateRange: YearMonthDayRange) {
private val segmentedCustomers = mutableMapOf<T, MutableSet<CustomerInfo>>()
private val customers = mutableSetOf<CustomerInfo>()
private val customersFree = mutableSetOf<CustomerInfo>()
private val customersPaying = mutableSetOf<CustomerInfo>()

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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<LicensePeriod>,
val customers: CustomerTracker<CustomerSegment>,
val amounts: PaymentAmountTracker,
val churnAnnualLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnAnnualPaidLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnMonthlyLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnMonthlyPaidLicenses: ChurnProcessor<Int, CustomerInfo>,
val downloads: Long,
) {
val isEmpty: Boolean
Expand All @@ -34,7 +62,9 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv
private data class YearData(
val year: Int,
val churnAnnualLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnAnnualPaidLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnMonthlyLicenses: ChurnProcessor<Int, CustomerInfo>,
val churnMonthlyPaidLicenses: ChurnProcessor<Int, CustomerInfo>,
val months: Map<Int, MonthData>
) {
val isEmpty: Boolean
Expand All @@ -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"
)
Expand All @@ -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,
)
Expand All @@ -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<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnAnnualCustomers.init()

val churnMonthlyCustomers = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnMonthlyCustomers.init()
val churnAnnual = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnAnnual.init()

val churnAnnualPaying = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnAnnualPaying.init()

val churnMonthly = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnMonthly.init()

val churnMonthlyPaying = SimpleChurnProcessor<CustomerInfo>(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
Expand All @@ -131,10 +183,23 @@ class OverviewTable(private val graceTimeDays: Int = 7) : SimpleDataTable("Overv
val churnAnnualCustomers = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnAnnualCustomers.init()

val churnAnnualPayingCustomers = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnAnnualPayingCustomers.init()

val churnMonthlyCustomers = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnMonthlyCustomers.init()

years[year] = YearData(year, churnAnnualCustomers, churnMonthlyCustomers, months)
val churnMonthlyPayingCustomers = SimpleChurnProcessor<CustomerInfo>(activeDate, churnDate, graceTimeDays)
churnMonthlyPayingCustomers.init()

years[year] = YearData(
year,
churnAnnualCustomers,
churnAnnualPayingCustomers,
churnMonthlyCustomers,
churnMonthlyPayingCustomers,
months
)
}
}

Expand All @@ -148,34 +213,62 @@ 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,
customer,
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
)
}
}
}
Expand All @@ -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
Expand All @@ -211,39 +313,56 @@ 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",
footer = SimpleRowGroup(
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,
)
)
)
Expand Down

0 comments on commit 36179f4

Please sign in to comment.