Coverage Summary for Class: CategoryCapUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryCapUseCase |
100%
(4/4)
|
100%
(18/18)
|
100%
(27/27)
|
100%
(295/295)
|
| CategoryCapUseCase$coverageOf-84wupCo$$inlined$map$1 |
0%
(0/2)
|
|
| CategoryCapUseCase$coverageOf-84wupCo$$inlined$map$1$1 |
|
| CategoryCapUseCase$coverageOf-84wupCo$$inlined$map$1$2 |
0%
(0/1)
|
|
| CategoryCapUseCase$coverageOf-84wupCo$$inlined$map$1$2$1 |
|
| CategoryCapUseCase$invoke$1 |
|
| Total |
57.1%
(4/7)
|
100%
(18/18)
|
100%
(27/27)
|
100%
(295/295)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.model.Budget
import com.georgeci.moneysurfer.domain.model.BudgetPeriod
import com.georgeci.moneysurfer.domain.model.Category
import com.georgeci.moneysurfer.domain.model.CategoryCapCoverage
import com.georgeci.moneysurfer.domain.model.resolveCategoryCapCoverage
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.repositories.BudgetRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.koin.core.annotation.Single
/**
* Both directions of the category form's monthly-cap shortcut: what the control may show, and what
* saving it does to the budget behind it.
*
* There is no `cap` field on `Category` to write to — the cap *is* a budget naming exactly one
* category (md/categories.md, decision 2). Read and write share [resolveCategoryCapCoverage] so
* the screen and the write path can never disagree about which budget the control speaks for.
*/
@Single
class CategoryCapUseCase(
private val budgetRepository: BudgetRepository,
private val getBudgets: GetBudgetsUseCase,
private val clock: ClockUseCase,
) {
/**
* Which budget the control speaks for, tracked live. Re-resolving on every emission means a
* budget created or archived elsewhere while the form is open is reflected here rather than
* silently overwritten on save.
*/
fun coverageOf(categoryId: CategoryId?): Flow<CategoryCapCoverage> =
getBudgets().map { budgets -> resolveCategoryCapCoverage(categoryId, budgets) }
/**
* Applies the control to the backing budget: creates a single-category budget, updates its
* amount, or deletes it when the field was cleared.
*
* Coverage is re-resolved here rather than trusted from the caller, so a screen holding a
* stale state cannot write over a budget that appeared in the meantime.
*
* @param category the category as it was just saved — its id, name, type and workspace.
* @param previousName the category's name before this save, or null when it was just created.
* Used to keep an untouched backing budget's name in step with a category rename.
* @param cap the amount typed into the control, or null when it is empty (no cap).
*/
suspend operator fun invoke(
category: Category,
previousName: String?,
cap: Money?,
timeZone: TimeZone = TimeZone.currentSystemDefault(),
) {
val budgets = budgetRepository.getByWorkspaceId(category.workspaceId).first()
val backing = when (val coverage = resolveCategoryCapCoverage(category.id, budgets)) {
is CategoryCapCoverage.Editable -> coverage.budget
// A shared limit already owns this category. The form shows the control read-only, and
// the shortcut stays out of it rather than creating a second overlapping limit.
is CategoryCapCoverage.Managed -> return
}
// Budgets cap spending, so an income category can never move one — it carries no cap, and
// converting an expense category to income takes its backing budget with it.
val target = cap?.takeIf { category.type == CategoryType.EXPENSE }
val now = clock.now()
when {
target == null -> backing?.let { budgetRepository.delete(it.id) }
backing == null -> budgetRepository.insert(
Budget(
workspaceId = category.workspaceId,
name = category.name,
categoryIds = listOf(category.id),
amount = target,
period = BudgetPeriod.MONTHLY,
startDate = now.toLocalDateTime(timeZone).date,
createdAt = now,
),
)
else -> budgetRepository.update(
backing.copy(
amount = target,
// Follow a category rename only while the budget still carries the old name.
// A name the user changed on the Budgets screen is theirs to keep.
name = if (backing.name == previousName) category.name else backing.name,
),
)
}
}
}