Coverage Summary for Class: GetSpentMonthUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GetSpentMonthUseCase |
100%
(6/6)
|
100%
(8/8)
|
100%
(25/25)
|
100%
(57/57)
|
| GetSpentMonthUseCase$baseCurrency-DBbEM4U$$inlined$map$1 |
0%
(0/2)
|
|
| GetSpentMonthUseCase$baseCurrency-DBbEM4U$$inlined$map$1$1 |
|
| GetSpentMonthUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2 |
0%
(0/1)
|
|
| GetSpentMonthUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2$1 |
|
| GetSpentMonthUseCase$expenseIn-2nQq0bk$$inlined$map$1 |
0%
(0/2)
|
|
| GetSpentMonthUseCase$expenseIn-2nQq0bk$$inlined$map$1$1 |
|
| GetSpentMonthUseCase$expenseIn-2nQq0bk$$inlined$map$1$2 |
0%
(0/1)
|
|
| GetSpentMonthUseCase$expenseIn-2nQq0bk$$inlined$map$1$2$1 |
|
| GetSpentMonthUseCase$invoke$$inlined$flatMapLatest$1 |
0%
(0/1)
|
|
| GetSpentMonthUseCase$invoke$1$1$1 |
100%
(1/1)
|
|
100%
(6/6)
|
100%
(14/14)
|
| GetSpentMonthUseCase$invoke$lambda$0$$inlined$flatMapLatest$1 |
0%
(0/1)
|
|
| Total |
46.7%
(7/15)
|
100%
(8/8)
|
100%
(31/31)
|
100%
(71/71)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.model.SpendScope
import com.georgeci.moneysurfer.domain.model.SpentMonth
import com.georgeci.moneysurfer.domain.model.expenseTotal
import com.georgeci.moneysurfer.domain.model.monthlySpendCap
import com.georgeci.moneysurfer.domain.model.previousSpentMonthWindow
import com.georgeci.moneysurfer.domain.model.spentMonthWindow
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.SpendAnalyticsRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
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.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.koin.core.annotation.Single
/**
* What the active workspace's month has cost so far, or null while nothing backs the figure —
* nobody signed in, no workspace selected, or a workspace row the device has not pulled yet.
*
* A missing budget is *not* a missing month: [SpentMonth.cap] simply resolves null and the widget
* says so. Null here means the app cannot name the amount or its currency at all, which is the one
* thing it must not print a number for.
*
* Two aggregate reads rather than one, both `netByMonth`: the month-to-date figure and the same
* stretch of the month before it. One window spanning both months would return a whole previous
* month beside a partial current one, which is the comparison
* [com.georgeci.moneysurfer.domain.model.SpentMonth.previousSpent] exists to avoid. Each window
* covers a single month, so the part-month caveat on
* [SpendAnalyticsRepository.netByMonth] is exactly what is wanted here rather than a hazard.
*
* The cap is looked up against *this* flow's workspace rather than against whatever
* [GetBudgetsUseCase] happens to be answering for — the two subscribe to
* `SessionPointers.currentWorkspaceId` separately, so mid-switch they can disagree about which
* workspace is current. [monthlySpendCap] refuses the mismatched pairing instead of measuring one
* workspace's spend against another's limit; see [GetBurnRateUseCase], which pairs them the same way.
*
* "Today" is read from the clock once per workspace-and-currency emission, not per row, so a
* session left open across midnight keeps yesterday's windows until something re-subscribes. That
* matches every other date-scoped query here, and a dashboard is re-collected on every return to
* the screen.
*/
@Single
@OptIn(ExperimentalCoroutinesApi::class)
class GetSpentMonthUseCase(
private val spendAnalytics: SpendAnalyticsRepository,
private val workspaceRepository: WorkspaceRepository,
private val getBudgets: GetBudgetsUseCase,
private val session: SessionPointers,
private val clock: ClockUseCase,
) {
operator fun invoke(timeZone: TimeZone = TimeZone.currentSystemDefault()): Flow<SpentMonth?> =
session.currentWorkspaceId.flatMapLatest { workspaceId ->
workspaceId ?: return@flatMapLatest flowOf(null)
val today = clock.now().toLocalDateTime(timeZone).date
baseCurrency(workspaceId).flatMapLatest { currency ->
currency ?: return@flatMapLatest flowOf(null)
combine(
expenseIn(workspaceId, currency, spentMonthWindow(today)),
expenseIn(workspaceId, currency, previousSpentMonthWindow(today)),
getBudgets(),
) { spent, previousSpent, budgets ->
SpentMonth(
today = today,
spent = spent,
previousSpent = previousSpent,
cap = budgets.monthlySpendCap(workspaceId),
currency = currency,
)
}
}
}.distinctUntilChanged()
/**
* Spend inside one window as a single magnitude.
*
* [distinctUntilChanged] here is about the *rows*, not the budgets: Room's invalidation tracker
* fires per write, so a sync pull re-runs the aggregate once per batch, and most of those
* batches leave this window's total exactly where it was. A budget edit that does not move the
* cap is absorbed by the one on the result instead.
*/
private fun expenseIn(
workspaceId: WorkspaceId,
currency: CurrencyCode,
window: TransactionPeriodWindow,
) = spendAnalytics.netByMonth(
SpendScope(workspaceId = workspaceId, baseCurrency = currency, window = window),
).map { months -> months.expenseTotal() }.distinctUntilChanged()
/**
* The workspace base currency, live — the same reason [GetDailySpendSeriesUseCase] observes it
* rather than reading it once: the query *filters* on it, so a device that has not pulled the
* workspace row yet resolves null, and a null would otherwise latch a zero on screen for as
* long as the caller stayed subscribed.
*
* Null is reported as "no figure" rather than queried with, because a spend amount the app
* cannot name a currency for is not one the widget can print.
*/
private fun baseCurrency(workspaceId: WorkspaceId): Flow<CurrencyCode?> =
workspaceRepository.getAll()
.map { workspaces -> workspaces.firstOrNull { it.id == workspaceId }?.baseCurrency }
.distinctUntilChanged()
}