Coverage Summary for Class: GetCategorySpendUseCase (com.georgeci.moneysurfer.domain.usecase)

Class Method, % Branch, % Line, % Instruction, %
GetCategorySpendUseCase 100% (6/6) 87.5% (7/8) 100% (25/25) 100% (66/66)
GetCategorySpendUseCase$baseCurrency-DBbEM4U$$inlined$map$1 0% (0/2)
GetCategorySpendUseCase$baseCurrency-DBbEM4U$$inlined$map$1$1
GetCategorySpendUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2 0% (0/1)
GetCategorySpendUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2$1
GetCategorySpendUseCase$breakdownOf$1 100% (1/1) 100% (7/7) 100% (14/14)
GetCategorySpendUseCase$invoke$$inlined$flatMapLatest$1 0% (0/1)
GetCategorySpendUseCase$invoke$1$1 100% (1/1) 100% (2/2) 100% (1/1) 100% (14/14)
GetCategorySpendUseCase$invoke$lambda$0$$inlined$flatMapLatest$1 0% (0/1)
Total 61.5% (8/13) 90% (9/10) 100% (33/33) 100% (94/94)


 package com.georgeci.moneysurfer.domain.usecase
 
 import com.georgeci.moneysurfer.domain.auth.SessionPointers
 import com.georgeci.moneysurfer.domain.dashboard.DashboardPeriod
 import com.georgeci.moneysurfer.domain.model.SpendScope
 import com.georgeci.moneysurfer.domain.model.SpentByCategory
 import com.georgeci.moneysurfer.domain.model.buildSpentByCategory
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 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 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.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import org.koin.core.annotation.Single
 
 /**
  * Where the chosen period's money went, category by category, for the active workspace — or null
  * while nothing backs it: nobody signed in, or a workspace whose base currency cannot be read.
  *
  * Spend comes from [SpendAnalyticsRepository.byCategory], one `GROUP BY` in SQLite, rather than
  * from folding the workspace's transactions in memory. That is the whole point of that interface,
  * and it is also why the per-category caps here are read straight off the budgets instead of
  * through `GetBudgetProgressUseCase`, which loads every transaction in the workspace per emission.
  *
  * Categories and budgets are read from the repositories with the workspace id this flow already
  * resolved, not through `GetCategoriesUseCase` / `GetBudgetsUseCase`. Those read
  * [SessionPointers.currentWorkspaceId] again, and two collectors of one pointer are not ordered
  * against each other — on a workspace switch that lets the new workspace's spend pair with the
  * previous one's categories, which is exactly the bug the safe-to-spend wiring had to unpick.
  *
  * [period] arrives as a flow and is required rather than defaulted, following
  * [GetSafeToSpendUseCase]: this is a spend figure, so it is only meaningful once the caller says
  * which span it answers for, and the dashboard's Week/Month switch has to reach it or the card
  * would keep showing the month while every widget beside it changed. Unlike safe-to-spend the
  * period here reshapes the query rather than re-picking a budget — the window *is* what
  * [SpendAnalyticsRepository.byCategory] groups over — so a change re-runs one `GROUP BY` rather
  * than the workspace's whole transaction list.
  */
 @Single
 @OptIn(ExperimentalCoroutinesApi::class)
 class GetCategorySpendUseCase(
     private val spendAnalytics: SpendAnalyticsRepository,
     private val categoryRepository: CategoryRepository,
     private val budgetRepository: BudgetRepository,
     private val workspaceRepository: WorkspaceRepository,
     private val session: SessionPointers,
     private val clock: ClockUseCase,
 ) {
 
     operator fun invoke(
         period: Flow<DashboardPeriod>,
         timeZone: TimeZone = TimeZone.currentSystemDefault(),
     ): Flow<SpentByCategory?> =
         session.currentWorkspaceId.flatMapLatest { workspaceId ->
             workspaceId ?: return@flatMapLatest flowOf(null)
 
             combine(
                 period.distinctUntilChanged(),
                 baseCurrency(workspaceId),
             ) { chosen, currency -> chosen to currency }
                 .flatMapLatest { (chosen, currency) ->
                     currency ?: return@flatMapLatest flowOf(null)
                     // Re-read on every emission rather than once per workspace: the window is the
                     // period's answer for *today*, and the period is the thing that changes.
                     val today = clock.now().toLocalDateTime(timeZone).date
                     breakdownOf(workspaceId, currency, chosen, today)
                 }
         }
 
     /**
      * The workspace base currency, live.
      *
      * Observed rather than read once because the query *filters* 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 widget 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
      * the aggregate.
      */
     private fun baseCurrency(workspaceId: WorkspaceId): Flow<CurrencyCode?> =
         workspaceRepository.getAll()
             .map { workspaces -> workspaces.firstOrNull { it.id == workspaceId }?.baseCurrency }
             .distinctUntilChanged()
 
     private fun breakdownOf(
         workspaceId: WorkspaceId,
         currency: CurrencyCode,
         period: DashboardPeriod,
         today: LocalDate,
     ): Flow<SpentByCategory> = combine(
         spendAnalytics.byCategory(SpendScope(workspaceId, currency, period.window(today))),
         categoryRepository.getByWorkspaceId(workspaceId),
         budgetRepository.getByWorkspaceId(workspaceId),
     ) { slices, categories, budgets ->
         buildSpentByCategory(
             slices = slices,
             categories = categories,
             budgets = budgets,
             currency = currency,
             window = period.window(today),
             // Only a cap on the selected cadence may speak: a monthly limit against a week of
             // spend would read as headroom until the month is already blown.
             capPeriod = period.budgetPeriod,
         )
     }
 }