Coverage Summary for Class: GetSpendInsightsUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GetSpendInsightsUseCase |
100%
(6/6)
|
50%
(4/8)
|
100%
(30/30)
|
100%
(102/102)
|
| GetSpendInsightsUseCase$baseCurrency-DBbEM4U$$inlined$map$1 |
0%
(0/2)
|
|
| GetSpendInsightsUseCase$baseCurrency-DBbEM4U$$inlined$map$1$1 |
|
| GetSpendInsightsUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2 |
0%
(0/1)
|
|
| GetSpendInsightsUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2$1 |
|
| GetSpendInsightsUseCase$breakdown$1 |
100%
(1/1)
|
|
100%
(7/7)
|
100%
(14/14)
|
| GetSpendInsightsUseCase$insightsOf$1 |
100%
(1/1)
|
|
100%
(7/7)
|
100%
(15/15)
|
| GetSpendInsightsUseCase$invoke$$inlined$flatMapLatest$1 |
0%
(0/1)
|
|
| GetSpendInsightsUseCase$invoke$1$1 |
100%
(1/1)
|
50%
(1/2)
|
100%
(1/1)
|
85.7%
(12/14)
|
| GetSpendInsightsUseCase$invoke$lambda$0$$inlined$flatMapLatest$1 |
0%
(0/1)
|
|
| Total |
64.3%
(9/14)
|
50%
(5/10)
|
100%
(45/45)
|
98.6%
(143/145)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.model.InsightsSelection
import com.georgeci.moneysurfer.domain.model.SpendInsights
import com.georgeci.moneysurfer.domain.model.SpendScope
import com.georgeci.moneysurfer.domain.model.SpentByCategory
import com.georgeci.moneysurfer.domain.model.buildNetTrend
import com.georgeci.moneysurfer.domain.model.buildSpentByCategory
import com.georgeci.moneysurfer.domain.model.trailingMonths
import com.georgeci.moneysurfer.domain.preferences.TransactionPeriodMode
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.domain.repositories.BudgetRepository
import com.georgeci.moneysurfer.domain.repositories.CategoryRepository
import com.georgeci.moneysurfer.domain.repositories.SpendAnalyticsRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
import com.georgeci.moneysurfer.domain.util.periodWindow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.yearMonth
import org.koin.core.annotation.Single
/**
* Everything the insights screen shows for the period it is pointed at — or null while nothing backs
* it: nobody signed in, or a workspace whose base currency cannot be read.
*
* One use case for all four rollups rather than one per card, because they have to agree. Each of
* them filters on the workspace and its base currency, and two collectors of
* [SessionPointers.currentWorkspaceId] are not ordered against each other — on a workspace switch
* that lets one card's spend pair with another card's categories, the bug the safe-to-spend wiring
* had to unpick. Resolving the workspace and the currency once and fanning out from there makes a
* mismatched screen unrepresentable.
*
* Spend comes from [SpendAnalyticsRepository], four `GROUP BY` rollups, rather than from folding the
* workspace's transactions in memory — the same reason [GetCategorySpendUseCase] gives, and the
* whole point of that interface.
*
* [selection] arrives as a flow and is required rather than defaulted: these are spend figures, so
* they are only meaningful once the caller says which span they answer for, and the screen's pager
* has to reach them or paging would change the label and nothing else.
*/
@Single
@OptIn(ExperimentalCoroutinesApi::class)
class GetSpendInsightsUseCase(
private val spendAnalytics: SpendAnalyticsRepository,
private val categoryRepository: CategoryRepository,
private val budgetRepository: BudgetRepository,
private val workspaceRepository: WorkspaceRepository,
private val session: SessionPointers,
) {
operator fun invoke(selection: Flow<InsightsSelection>): Flow<SpendInsights?> =
session.currentWorkspaceId.flatMapLatest { workspaceId ->
workspaceId ?: return@flatMapLatest flowOf(null)
combine(
selection.distinctUntilChanged(),
baseCurrency(workspaceId),
) { chosen, currency -> chosen to currency }
.flatMapLatest { (chosen, currency) ->
// The aggregates *filter* on the base currency, so a null one matches nothing.
// Emitting null rather than querying keeps a device that has not pulled the
// workspace row yet from drawing a screen built from no rows at all.
currency ?: return@flatMapLatest flowOf(null)
insightsOf(workspaceId, currency, chosen)
}
}
/**
* The workspace base currency, live.
*
* Observed rather than read once because the queries *filter* on it: the session pointer lives
* in preferences and is restored independently of the `workspaces` row, so a device that has not
* pulled the workspace yet resolves null — which matches no transaction and would latch an empty
* screen for as long as the caller stayed subscribed. Re-pointing after a currency change falls
* out of the same subscription, and [distinctUntilChanged] keeps a rename from re-running four
* aggregates.
*/
private fun baseCurrency(workspaceId: WorkspaceId): Flow<CurrencyCode?> =
workspaceRepository.getAll()
.map { workspaces -> workspaces.firstOrNull { it.id == workspaceId }?.baseCurrency }
.distinctUntilChanged()
private fun insightsOf(
workspaceId: WorkspaceId,
currency: CurrencyCode,
selection: InsightsSelection,
): Flow<SpendInsights> {
val scope = SpendScope(workspaceId, currency, selection.window)
val months = trailingMonths(selection.anchor.yearMonth, SpendInsights.MONTH_COLUMNS)
val monthsScope = SpendScope(workspaceId, currency, monthsWindow(months))
return combine(
breakdown(scope, workspaceId, selection),
spendAnalytics.netByMonth(monthsScope),
spendAnalytics.topMerchants(scope, SpendInsights.TOP_MERCHANTS),
spendAnalytics.excludedByCurrency(scope),
) { breakdown, monthRows, merchants, excluded ->
SpendInsights(
selection = selection,
currency = currency,
breakdown = breakdown,
months = buildNetTrend(months, monthRows),
merchants = merchants,
excludedByCurrency = excluded,
)
}
}
/**
* The period's spend joined to the categories that name it, through the same builder the
* dashboard's donut and spent-by-category widgets go through — so a category's figure on this
* screen can never disagree with the same category's figure on the dashboard.
*
* The budgets arm is what that builder needs to resolve per-category caps. This screen draws no
* cap overlay today; carrying them costs one read of tens of rows and is the difference between
* reusing the shared join and forking a second, cap-less one.
*/
private fun breakdown(
scope: SpendScope,
workspaceId: WorkspaceId,
selection: InsightsSelection,
): Flow<SpentByCategory> = combine(
spendAnalytics.byCategory(scope),
categoryRepository.getByWorkspaceId(workspaceId),
budgetRepository.getByWorkspaceId(workspaceId),
) { slices, categories, budgets ->
buildSpentByCategory(
slices = slices,
categories = categories,
budgets = budgets,
currency = scope.baseCurrency,
window = scope.window,
// Only a cap on the selected cadence may speak — see `buildSpentByCategory`.
capPeriod = selection.mode.budgetPeriod,
)
}
}
/**
* The span [months] covers, whole months at both ends.
*
* Built through `periodWindow(Month, ...)` rather than by hand because `netByMonth` groups *inside*
* the window it is given: an end that is not a month boundary comes back as a part-month row under a
* full month's name, which `MonthlyNet.month` cannot express.
*/
private fun monthsWindow(months: List<YearMonth>): TransactionPeriodWindow = TransactionPeriodWindow(
from = wholeMonth(months.first()).from,
to = wholeMonth(months.last()).to,
)
private fun wholeMonth(month: YearMonth): TransactionPeriodWindow =
periodWindow(TransactionPeriodMode.Month, LocalDate(month.year, month.month, 1))