Coverage Summary for Class: CategoryCapCoverage (com.georgeci.moneysurfer.domain.model)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryCapCoverage$Editable |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(7/7)
|
| CategoryCapCoverage$Managed |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(7/7)
|
| Total |
100%
(2/2)
|
|
100%
(2/2)
|
100%
(14/14)
|
package com.georgeci.moneysurfer.domain.model
import com.georgeci.moneysurfer.domain.primitives.CategoryId
/**
* What the category form's monthly-cap shortcut is allowed to do for one category.
*
* Budgets owns limits — `Category` carries no `cap` field (md/categories.md, decision 1). A
* per-category cap *is* a budget naming exactly that one category, so the shortcut may only ever
* touch such a budget. It must never add a second limit on top of one the user already built on
* the Budgets screen, which would leave two caps on one category and no defined winner.
*/
sealed interface CategoryCapCoverage {
/**
* The control is an editable amount field. [budget] is the single-category budget backing the
* cap today, or null when there is none and saving an amount should create one.
*/
data class Editable(val budget: Budget?) : CategoryCapCoverage
/**
* A limit the shortcut must not compete with already covers this category. The control goes
* read-only and names [budget]; the user changes the limit on the Budgets screen instead.
*/
data class Managed(val budget: Budget) : CategoryCapCoverage
}
/**
* Resolves which budget, if any, the monthly-cap control on the category form speaks for.
*
* Two kinds of budget are deliberately not treated as coverage:
* - **archived** ones (`isActive = false`) limit nothing, so a cap set here is a fresh budget
* rather than the resurrection of an old one;
* - the **all-categories** budget (empty `categoryIds`) is the global spending envelope, which by
* design sits above per-category budgets rather than competing with them — treating it as
* coverage would leave the shortcut permanently dead for anyone who keeps a general budget.
*
* Everything else that names this category counts. A budget naming it *alongside other
* categories*, or several budgets naming it at once, both resolve to [CategoryCapCoverage.Managed].
*/
fun resolveCategoryCapCoverage(
categoryId: CategoryId?,
budgets: List<Budget>,
): CategoryCapCoverage {
// A category that does not exist yet cannot be covered by anything.
if (categoryId == null) return CategoryCapCoverage.Editable(budget = null)
val covering = budgets
.filter { it.isActive && categoryId in it.categoryIds }
// Stable order so the read-only notice always names the same budget across recompositions.
.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
val only = covering.singleOrNull()
return when {
covering.isEmpty() -> CategoryCapCoverage.Editable(budget = null)
only != null && only.categoryIds.size == 1 -> CategoryCapCoverage.Editable(budget = only)
else -> CategoryCapCoverage.Managed(budget = covering.first())
}
}