Coverage Summary for Class: InsightInput (com.georgeci.moneysurfer.domain.insight)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| InsightInput |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(9/9)
|
100%
(42/42)
|
package com.georgeci.moneysurfer.domain.insight
import com.georgeci.moneysurfer.domain.model.CategorySpendSlice
import com.georgeci.moneysurfer.domain.model.RecurringFrequency
import com.georgeci.moneysurfer.domain.model.RecurringRule
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* Everything the rules read, gathered by
* [com.georgeci.moneysurfer.domain.usecase.GenerateInsightsUseCase].
*
* A parameter object rather than six arguments so [generateInsights] stays a pure function of one
* value: a rule spec builds the input it wants and asserts on the output, with no fakes and no
* coroutines in between.
*
* The two spend lists must cover comparable stretches of the calendar — month-to-date against the
* same stretch of the previous month, not against a whole month. Nothing here can check that; see
* the use case for why it matters.
*/
data class InsightInput(
/** Names the period the ids belong to, e.g. `2026-07`. See [Insight.id]. */
val periodKey: String,
/** How many days [currentSpend] covers. */
val elapsedDays: Int,
/**
* How many days [previousSpend] covers. Usually equal to [elapsedDays], but a baseline clamped
* to a shorter month is not — see [generateInsights] for why that silences the comparison.
*/
val baselineDays: Int,
val currency: CurrencyCode,
val currentSpend: List<CategorySpendSlice>,
val previousSpend: List<CategorySpendSlice>,
/** Display names for the categories the slices reference. A missing id reads as uncategorized. */
val categoryNames: Map<CategoryId, String>,
/**
* Recurring rules that book an expense. Telling an expense schedule from an income one needs
* the category tree, so the caller does that; whether a rule is live is a property of the rule
* itself, so [generateInsights] does that.
*/
val expenseSchedules: List<RecurringRule>,
)
/**
* Every rule, run once, ordered for a card that may only have room for the first one.
*
* Each rule returns at most one insight — the point of the widget is two or three sentences worth
* reading, not a log. Warn comes before Good and Good before Neutral so the compact card, which
* shows exactly one, shows the actionable one.
*
* The comparison rules wait for [MIN_COMPARISON_DAYS] and for two windows of equal length; the
* subscription count waits for neither, because it reads the schedules rather than the window and
* is just as true on the 1st as on the 30th.
*/
fun generateInsights(input: InsightInput): List<Insight> {
val comparisons = if (input.canCompare) periodComparisons(input) else emptyList()
return (comparisons + listOfNotNull(activeSubscriptions(input)))
.sortedBy { TONE_PRIORITY.indexOf(it.tone) }
}
/**
* Whether the two windows can be measured against each other at all.
*
* Two terms. A sample shorter than [MIN_COMPARISON_DAYS] is too small to read anything into. And
* windows of *different* lengths are not comparable however long they are: on 31 March the
* baseline clamps to a 28-day February, so a user who spent at exactly the same daily rate in both
* months measures 31/28 — up 10.7%, enough on its own to trip the 10% period threshold and print
* "Spending is up 11%" every non-leap year. Saying nothing beats inventing a warning.
*/
private val InsightInput.canCompare: Boolean
get() = elapsedDays >= MIN_COMPARISON_DAYS && elapsedDays == baselineDays
/** The rules that need a baseline: the two category movers, and the period total. */
private fun periodComparisons(input: InsightInput): List<Insight> {
val previousTotal = input.previousSpend.total()
val changes = categoryChanges(input, floor = previousTotal.share(MIN_DELTA_SHARE))
return listOfNotNull(
changes.firstOrNull { it.isIncrease },
changes.firstOrNull { !it.isIncrease },
periodSpend(input, current = input.currentSpend.total(), previous = previousTotal),
)
}
/** Every category that moved enough to be worth a sentence, biggest move in money first. */
private fun categoryChanges(input: InsightInput, floor: Money): List<Insight.CategoryChange> {
val current = input.currentSpend.associate { it.categoryId to it.total }
val previous = input.previousSpend.associate { it.categoryId to it.total }
return (current.keys + previous.keys)
.mapNotNull { categoryId ->
categoryChange(
input = input,
categoryId = categoryId,
current = current[categoryId] ?: Money.zero(),
previous = previous[categoryId] ?: Money.zero(),
floor = floor,
)
}
.sortedByDescending { (it.current - it.previous).abs().minor }
}
private fun categoryChange(
input: InsightInput,
categoryId: CategoryId?,
current: Money,
previous: Money,
floor: Money,
): Insight.CategoryChange? {
// No baseline, no percentage. A category that booked nothing last period cannot be "up 40%",
// and reading it as an infinite rise would push every real finding off the card.
if (!previous.isPositive()) return null
val delta = current - previous
val ratio = delta.minor.toDouble() / previous.minor.toDouble()
// Both terms have to clear. A large percentage of pocket change is noise, and a large amount
// that barely shifted a large category is not news either.
if (delta.abs() < floor || abs(ratio) < CATEGORY_CHANGE_RATIO) return null
return Insight.CategoryChange(
id = "category-change:${categoryId?.value ?: UNCATEGORIZED_KEY}:${input.periodKey}",
categoryId = categoryId,
categoryName = categoryId?.let(input.categoryNames::get),
current = current,
previous = previous,
changePercent = ratio.toWholePercent(),
currency = input.currency,
)
}
/**
* The whole period against the whole previous one.
*
* Fires even when the total barely moved, as [SpendTrend.Flat]: "in line with last month" is an
* answer to the question the widget exists to answer, and a workspace whose categories all sat
* still would otherwise show an empty card.
*
* No floor here, unlike the category rules: this figure *is* the total the floor is a share of.
*/
private fun periodSpend(input: InsightInput, current: Money, previous: Money): Insight.PeriodSpend? {
if (!previous.isPositive()) return null
val ratio = (current - previous).minor.toDouble() / previous.minor.toDouble()
return Insight.PeriodSpend(
id = "period-spend:${input.periodKey}",
trend = when {
ratio >= PERIOD_CHANGE_RATIO -> SpendTrend.Up
ratio <= -PERIOD_CHANGE_RATIO -> SpendTrend.Down
else -> SpendTrend.Flat
},
current = current,
previous = previous,
changePercent = ratio.toWholePercent(),
currency = input.currency,
)
}
private fun activeSubscriptions(input: InsightInput): Insight.ActiveSubscriptions? {
val active = input.expenseSchedules.filter { it.isActive }
if (active.isEmpty()) return null
return Insight.ActiveSubscriptions(
id = "active-subscriptions:${input.periodKey}",
count = active.size,
monthlyTotal = active.fold(Money.zero()) { total, rule -> total + rule.monthlyCost() },
currency = input.currency,
)
}
/**
* What one schedule costs in a month, to the nearest minor unit.
*
* An estimate by construction: a 30-day month and a 12-month year put daily, weekly, monthly and
* yearly rules on one scale so they can be added together at all. It fills one sentence on the
* dashboard — nothing is booked against it, and no balance is derived from it.
*
* [RecurringRule.amount] carries no currency of its own; a rule is written in the workspace base
* currency, which is the currency the rest of the insight is already in. The magnitude is taken
* because the aggregates this sits beside are magnitudes too.
*/
private fun RecurringRule.monthlyCost(): Money {
val every = schedule.interval.coerceAtLeast(1)
val amount = amount.abs()
return when (schedule.frequency) {
RecurringFrequency.DAILY -> amount * DAYS_PER_MONTH / every
RecurringFrequency.WEEKLY -> amount * DAYS_PER_MONTH / (DAYS_PER_WEEK * every)
RecurringFrequency.MONTHLY -> amount / every
RecurringFrequency.YEARLY -> amount / (MONTHS_PER_YEAR * every)
}
}
private fun List<CategorySpendSlice>.total(): Money =
fold(Money.zero()) { running, slice -> running + slice.total }
/** [fraction] of this amount, rounded to the nearest minor unit. */
private fun Money.share(fraction: Double): Money = Money((minor.toDouble() * fraction).roundToLong())
private fun Double.toWholePercent(): Int = (abs(this) * PERCENT_SCALE).roundToInt()
/** Warn first: the compact card shows one insight, and it should be the one worth acting on. */
private val TONE_PRIORITY = listOf(InsightTone.Warn, InsightTone.Good, InsightTone.Neutral)
/**
* Days of the period that must have passed before the comparison rules say anything.
*
* A month-to-date window against the same stretch of last month is symmetric by construction, but
* on the 1st that stretch is a *single day*, and one bill landing a day either side of the
* boundary swings the answer by 100%. A user who pays rent on the 1st and has not been billed yet
* would be congratulated with "Rent is down 100%" every month. The relative floor cannot filter
* that — the floor is a share of the very same one-day baseline. A week is the shortest span over
* which a single charge's timing stops dominating the comparison.
*/
private const val MIN_COMPARISON_DAYS = 7
/** A single category has to move by a fifth before the move is worth a sentence. */
private const val CATEGORY_CHANGE_RATIO = 0.20
/** The whole period's spend is a steadier number, so a tenth of it is already notable. */
private const val PERIOD_CHANGE_RATIO = 0.10
/**
* Floor under the category rules, as a share of the previous period's *total* spend.
*
* Relative rather than absolute: a fixed "20" floor means something different in every currency
* the app supports, while "a twentieth of what you spent last month" reads the same everywhere.
* It is what keeps a category that went from 2 to 5 — up 150% — from pushing the one that went
* from 300 to 380 off the card.
*/
private const val MIN_DELTA_SHARE = 0.05
private const val UNCATEGORIZED_KEY = "uncategorized"
private const val PERCENT_SCALE = 100
private const val DAYS_PER_MONTH = 30
private const val DAYS_PER_WEEK = 7
private const val MONTHS_PER_YEAR = 12