M2b: Android skeleton, :core:model contract, :domain passing all vectors

Gradle project in android/ with every module of SPEC §6.2 (build-logic convention plugins, version catalog,
architecture check in check), :core:model types and repository interfaces of §14.2.4 and §14.5, :domain
implementing §14.5.2 and §14.5.4 (125 vector cases), scenario-driven fakes with the empty scenario, Room 3
entities and KvittDatabase v1 with exported schema, :app shell with Hilt, en/sv strings with MissingTranslation
as a lint error, Roborazzi goldens of the empty screen in light and dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:23:43 +02:00
parent 76ee957f41
commit c277ee37c2
93 changed files with 4915 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
// :core:design — theme, tokens, shared components and formatters. M2b ships only the placeholder theme; M6 owns it.
plugins {
id("kvitt.android.library")
id("kvitt.android.compose")
}
dependencies {
api(project(":core:model"))
}
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.design
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
/**
* Placeholder theme from M2b: Material 3 baseline colours, light and dark following the system (SPEC §13.1).
* M6 replaces the colour scheme, typography and shapes with the tokens of docs/ux/design-spec.md.
*/
@Composable
fun KvittTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content)
}
+12
View File
@@ -0,0 +1,12 @@
// :core:fakes — scenario-driven fakes of every repository interface of SPEC §14.2.4. Only for tests, screenshot tests
// and debug previews (the architecture check rejects it in release configurations). Computed values come from :domain.
// M7x add their scenarios under src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/ and nowhere else (SPEC §11.3).
plugins {
id("kvitt.android.library")
}
dependencies {
api(project(":core:model"))
implementation(project(":domain"))
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,107 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.domain.resolveRange
import se.hagstedt.kvitt.domain.spendSeries
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.AnalyticsState
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DrillDownState
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SpendBucket
import se.hagstedt.kvitt.model.analytics.AnalyticsExpense
import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity
import se.hagstedt.kvitt.model.analytics.AnalyticsGroupBy
import se.hagstedt.kvitt.model.analytics.AnalyticsMeasure
import se.hagstedt.kvitt.model.analytics.DateRange
/** FR-14 from the scenario's non-deleted expenses through `resolveRange` and `spendSeries` (§14.5.4). */
class FakeAnalyticsRepository(private val store: FakeStore) : AnalyticsRepository {
override fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState> = store.derive { s ->
val range = s.range(filter)
val expenses = s.analyticsExpenses()
val measure = s.measure(filter.measure)
val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK
val chosen = if (filter.groupBy == GroupBy.CATEGORY) AnalyticsGroupBy.CATEGORY else AnalyticsGroupBy.ACCOUNT
val series = spendSeries(expenses, range, granularity, measure, chosen)
val byCategory = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.CATEGORY).groupTotals
val byAccount = spendSeries(expenses, range, granularity, measure, AnalyticsGroupBy.ACCOUNT).groupTotals
fun ranked(totals: Map<String, Long>, groupBy: GroupBy) =
totals.entries.sortedByDescending { it.value }.map { s.groupKey(it.key, groupBy) to it.value }
AnalyticsState(
filter = filter,
start = range.start.toString(),
end = range.endInclusive.toString(),
groups = ranked(series.groupTotals, filter.groupBy).map { it.first },
buckets = series.buckets.map { SpendBucket(it.key, it.start.toString(), it.endInclusive.toString(), it.totals, it.total) },
byCategory = ranked(byCategory, GroupBy.CATEGORY),
byAccount = ranked(byAccount, GroupBy.ACCOUNT),
totalOre = series.grandTotal,
)
}
override fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState> =
store.derive { s ->
val range = s.range(filter)
val granularity = if (filter.granularity == Granularity.MONTH) AnalyticsGranularity.MONTH else AnalyticsGranularity.WEEK
val bucket = bucketKey?.let { key ->
spendSeries(emptyList(), range, granularity, AnalyticsMeasure.Household, AnalyticsGroupBy.CATEGORY).buckets.firstOrNull { it.key == key }
}
val start = bucket?.start ?: range.start
val end = bucket?.endInclusive ?: range.endInclusive
val matching = s.expenses.filter { e ->
val date = LocalDate.parse(e.date)
!e.deleted && date >= start && date <= end &&
(categoryId == null || e.categoryId == categoryId) && (accountId == null || e.accountId == accountId)
}
val measured = matching.sumOf { e ->
if (filter.measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == s.me.id }?.shareOre ?: 0L
}
// TODO(spec): DrillDownState.title is a String but S-12's title ("Mat & hushåll, oktober 2026") needs the
// UI's localised names and month format; the fakes join the raw parts (group label or key, bucket key).
val title = listOfNotNull(
categoryId?.let { id -> s.category(id)?.let { it.customName ?: it.defaultKey?.name?.lowercase() } },
accountId?.let { id -> s.account(id)?.name },
bucketKey,
).joinToString(", ")
DrillDownState(
title = title,
totalOre = measured,
rows = matching.sortedWith { a, b -> ledgerOrder.compare(a.date to a.createdAt, b.date to b.createdAt) }
.mapNotNull { s.expenseRow(it) },
)
}
}
private fun FakeScenario.analyticsExpenses(): List<AnalyticsExpense> = expenses.filter { !it.deleted }.map { e ->
AnalyticsExpense(LocalDate.parse(e.date), e.accountId.value, e.categoryId.value, e.amountOre, e.shares.associate { it.userId.value to it.shareOre })
}
private fun FakeScenario.measure(measure: Measure): AnalyticsMeasure =
if (measure == Measure.HOUSEHOLD) AnalyticsMeasure.Household else AnalyticsMeasure.Share(me.id.value)
/**
* AC-14.2 via `resolveRange`. The UI only sends CUSTOM with both dates; the real repository maps
* IncompleteCustomRangeException to `validation_failed`. The fakes fill a missing custom date with today instead, since
* AnalyticsState has no error field (TODO(spec): how an analytics flow reports `validation_failed`, §14.2.4).
*/
private fun FakeScenario.range(filter: AnalyticsFilter): DateRange {
val today = LocalDate.parse(today)
val earliest = expenses.filter { !it.deleted }.minOfOrNull { it.date }?.let { LocalDate.parse(it) }
val custom = filter.preset == RangePreset.CUSTOM
val start = filter.customStart?.let { LocalDate.parse(it) } ?: today.takeIf { custom }
val end = filter.customEnd?.let { LocalDate.parse(it) } ?: today.takeIf { custom }
return resolveRange(filter.preset, today, earliest, start, end)
}
private fun FakeScenario.groupKey(id: String, groupBy: GroupBy): GroupKey = when (groupBy) {
GroupBy.CATEGORY -> categories.firstOrNull { it.id.value == id }.let { GroupKey(id, it?.customName, it?.color) }
GroupBy.ACCOUNT -> GroupKey(id, accounts.firstOrNull { it.id.value == id }?.name, null)
}
@@ -0,0 +1,176 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import se.hagstedt.kvitt.domain.BP_TOTAL
import se.hagstedt.kvitt.domain.computeSplit
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseEditorOptions
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.model.money.SplitError
import se.hagstedt.kvitt.model.money.SplitResult
import se.hagstedt.kvitt.model.money.SplitSpec
class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
override fun editorOptions(): Flow<ExpenseEditorOptions> = store.derive { s ->
val partner = s.partner ?: return@derive null
val active = s.accountsInOrder().filter { !it.archived }
val categories = s.categories.filter { !it.archived }.sortedBy { it.sortOrder }
val other = categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }
ExpenseEditorOptions(
me = s.me,
partner = partner,
accounts = active,
categories = categories,
// AC-8.6: the account last used on this phone if still active, else the oldest active account.
defaultAccountId = s.defaultAccountId?.takeIf { id -> active.any { it.id == id } } ?: active.firstOrNull()?.id,
// AC-8.6: the category last used in that account if still active, else `other`.
defaultCategoryFor = active.mapNotNull { account ->
val last = s.lastCategoryFor[account.id]?.takeIf { id -> categories.any { it.id == id } }
(last ?: other?.id)?.let { account.id to it }
}.toMap(),
today = s.today,
)
}.filterNotNull()
override suspend fun load(id: ExpenseId): ExpenseDraft? {
val e = store.current.expenses.firstOrNull { it.id == id && !it.deleted } ?: return null
return ExpenseDraft(
id = e.id,
accountId = e.accountId,
categoryId = e.categoryId,
amountOre = e.amountOre,
description = e.description,
notes = e.notes,
date = e.date,
payer = e.payer,
mode = e.mode,
ratioBp = if (e.mode == SplitMode.RATIO) e.shares.associate { it.userId to (it.shareBp ?: 0) } else null,
exactOre = if (e.mode == SplitMode.EXACT) e.shares.associate { it.userId to it.shareOre } else null,
fullOn = if (e.mode == SplitMode.FULL) e.shares.firstOrNull { it.shareBp == BP_TOTAL }?.userId else null,
)
}
override fun preview(draft: ExpenseDraft): List<Share>? = (store.current.split(draft) as? SplitOutcome.Ok)?.shares
override suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId> {
val s = store.current
val amountOre = draft.amountOre
val accountId = draft.accountId?.takeIf { s.account(it) != null }
val categoryId = draft.categoryId?.takeIf { s.category(it) != null }
val errors = mutableListOf<FieldError>()
if (amountOre == null) errors += FieldError("amountOre", "required")
if (accountId == null) errors += FieldError("accountId", "required")
if (categoryId == null) errors += FieldError("categoryId", "required")
// TODO(spec): §14.2.4 names only domain tokens and "required" for FieldError.error; the §9.6 length and date
// limits have no domain token, so the fakes use the REST code `validation_failed` for them.
when (draft.description.codePointLength()) {
0 -> errors += FieldError("description", "required")
!in 1..100 -> errors += FieldError("description", "validation_failed")
}
if (draft.notes.codePointLength() > 1000) errors += FieldError("notes", "validation_failed")
if (!s.validDate(draft.date)) errors += FieldError("date", "validation_failed")
val outcome = s.split(draft)
if (outcome is SplitOutcome.Err) {
val field = if (outcome.error == SplitError.AMOUNT_OUT_OF_RANGE) "amountOre" else "split"
errors += FieldError(field, outcome.error.name.lowercase())
}
if (errors.isNotEmpty() || outcome !is SplitOutcome.Ok || amountOre == null || accountId == null || categoryId == null) {
return SaveResult.Invalid(errors)
}
val existing = draft.id?.let { id -> s.expenses.firstOrNull { it.id == id } }
val id = draft.id ?: ExpenseId(store.newId())
val saved = FakeExpense(
id = id,
accountId = accountId,
categoryId = categoryId,
amountOre = amountOre,
description = draft.description.trim(),
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = draft.mode,
shares = outcome.shares,
createdAt = existing?.createdAt ?: s.now,
recurringTemplateId = existing?.recurringTemplateId,
pending = true,
timeline = existing?.timeline.orEmpty(),
)
store.edit { scenario ->
scenario.copy(
expenses = scenario.expenses.filter { it.id != id } + saved,
defaultAccountId = accountId,
lastCategoryFor = scenario.lastCategoryFor + (accountId to categoryId),
)
}
return SaveResult.Saved(id)
}
override suspend fun delete(id: ExpenseId) = store.edit { s ->
s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
}
override fun detail(id: ExpenseId): Flow<ExpenseDetailState?> = store.derive { s ->
val e = s.expenses.firstOrNull { it.id == id } ?: return@derive null
val account = s.account(e.accountId) ?: return@derive null
val category = s.category(e.categoryId) ?: return@derive null
val payer = s.user(e.payer) ?: return@derive null
ExpenseDetailState(
id = e.id,
account = account,
category = category,
amountOre = e.amountOre,
description = e.description,
notes = e.notes,
date = e.date,
payer = payer,
mode = e.mode,
shares = e.shares,
recurringTemplateName = e.recurringTemplateId?.let { t -> s.templates.firstOrNull { it.id == t }?.draft?.description },
deleted = e.deleted,
pending = e.pending,
timeline = s.timelineWithPending(e.timeline, e.pending, e.deleted),
)
}
}
internal sealed interface SplitOutcome {
data class Ok(val shares: List<Share>) : SplitOutcome
data class Err(val error: SplitError) : SplitOutcome
data object Incomplete : SplitOutcome
}
/** §9.1 through `computeSplit`; mode `default` copies the account's default ratio (AC-8.5). */
internal fun FakeScenario.split(draft: ExpenseDraft): SplitOutcome {
val amount = draft.amountOre ?: return SplitOutcome.Incomplete
val userIds = users.map { it.id }
val bp: Map<UserId, Int>? = when (draft.mode) {
SplitMode.DEFAULT -> draft.accountId?.let { account(it) }?.defaultShares ?: return SplitOutcome.Incomplete
SplitMode.RATIO -> draft.ratioBp ?: return SplitOutcome.Incomplete
SplitMode.FULL -> {
val bearer = draft.fullOn ?: return SplitOutcome.Incomplete
userIds.associateWith { if (it == bearer) BP_TOTAL else 0 }
}
SplitMode.EXACT -> null
}
val spec = when (draft.mode) {
SplitMode.EXACT -> SplitSpec.Exact((draft.exactOre ?: return SplitOutcome.Incomplete).mapKeys { it.key.value })
SplitMode.FULL -> SplitSpec.Full((draft.fullOn ?: return SplitOutcome.Incomplete).value)
else -> SplitSpec.Ratio(bp.orEmpty().mapKeys { it.key.value })
}
return when (val result = computeSplit(amount, draft.payer.value, userIds.map { it.value }, spec)) {
is SplitResult.Err -> SplitOutcome.Err(result.error)
is SplitResult.Ok -> SplitOutcome.Ok(
userIds.map { u -> Share(u, result.shares[u.value] ?: 0L, bp?.get(u)) },
)
}
}
@@ -0,0 +1,158 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountDetailState
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.BalanceView
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.HomeState
import se.hagstedt.kvitt.model.LedgerMonth
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncSummary
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.domain.BP_TOTAL
class FakeSessionRepository(private val store: FakeStore) : SessionRepository {
override val session: Flow<SessionState> = store.derive { it.session }
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> =
store.online { s ->
store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) }
OnlineResult.Ok(s.me)
}
override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) }
}
internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary(
lastSuccessAt = lastSuccessAt,
pendingCount = expenses.count { it.pending } + settlements.count { it.pending },
rejectedCount = rejections.size,
clockSkew = clockSkew,
running = syncRunning,
)
internal fun UpdateState.info(): UpdateInfo? = when (this) {
UpdateState.None -> null
is UpdateState.Available -> info
is UpdateState.Downloading -> info
is UpdateState.Failed -> info
is UpdateState.NeedsInstallPermission -> info
}
class FakeHomeRepository(private val store: FakeStore) : HomeRepository {
override fun home(): Flow<HomeState> = store.derive { s ->
val balances = s.balances()
val month = s.today.take(7)
val summaries = s.accountsInOrder().map { account ->
AccountSummary(
account = account,
netOre = balances.net(account.id, s.me.id),
expensesThisMonth = s.expenses.count { !it.deleted && it.accountId == account.id && it.date.startsWith(month) },
)
}
HomeState(
me = s.me,
partner = s.partner,
global = s.partner?.let { BalanceView(balances.globalNet(s.me.id), it) },
active = summaries.filter { !it.account.archived },
archived = summaries.filter { it.account.archived },
sync = s.syncSummary(),
update = s.update.info(),
)
}
}
class FakeAccountRepository(private val store: FakeStore) : AccountRepository {
override fun detail(id: AccountId): Flow<AccountDetailState?> = store.derive { s ->
val account = s.account(id) ?: return@derive null
val partner = s.partner ?: return@derive null
AccountDetailState(
account = account,
balance = BalanceView(s.balances().net(id, s.me.id), partner),
months = s.ledgerMonths(id),
deletable = s.accountDeletable(id),
users = s.users,
)
}
override fun all(): Flow<List<Account>> = store.derive { it.accountsInOrder() }
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = store.online { s ->
s.validateAccount(null, name, defaultShares) ?: run {
val id = AccountId(store.newId())
store.edit { it.copy(accounts = it.accounts + Account(id, name.trim(), defaultShares, it.now, archived = false)) }
OnlineResult.Ok(id)
}
}
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = store.online { s ->
if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null)
s.validateAccount(id, name, defaultShares) ?: run {
store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(name = name.trim(), defaultShares = defaultShares) else a }) }
OnlineResult.Ok(Unit)
}
}
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = store.online { s ->
if (s.account(id) == null) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(accounts = it.accounts.map { a -> if (a.id == id) a.copy(archived = archived) else a }) }
OnlineResult.Ok(Unit)
}
override suspend fun delete(id: AccountId): OnlineResult<Unit> = store.online { s ->
when {
s.account(id) == null -> OnlineResult.Failed("not_found", null)
!s.accountDeletable(id) -> OnlineResult.Failed("account_not_empty", null)
else -> {
store.edit { it.copy(accounts = it.accounts.filter { a -> a.id != id }) }
OnlineResult.Ok(Unit)
}
}
}
}
/** AC-6.1, AC-6.2, AC-5.2, §9.6: the server's answers to an account write, or null when it is accepted. */
private fun FakeScenario.validateAccount(id: AccountId?, name: String, shares: Map<UserId, Int>): OnlineResult.Failed? {
val key = name.trim().lowercase()
return when {
partner == null -> OnlineResult.Failed("household_incomplete", null)
name.codePointLength() !in 1..60 -> OnlineResult.Failed("validation_failed", "name")
shares.keys != users.map { it.id }.toSet() || shares.values.any { it !in 0..BP_TOTAL } || shares.values.sum() != BP_TOTAL ->
OnlineResult.Failed("validation_failed", "default_shares")
accounts.any { it.id != id && it.name.trim().lowercase() == key } -> OnlineResult.Failed("name_taken", "name")
else -> null
}
}
/** AC-6.5 */
internal fun FakeScenario.accountDeletable(id: AccountId): Boolean =
expenses.none { !it.deleted && it.accountId == id } &&
settlements.none { !it.deleted && id in it.allocations } &&
templates.none { it.draft.accountId == id }
/**
* S-5 / AC-10.3: expenses and settlements of one account interleaved by date, grouped by month, newest first; the
* month total is the household spend of the account's expenses in that month.
*/
private fun FakeScenario.ledgerMonths(id: AccountId): List<LedgerMonth> {
val expenseRows = expenses.filter { !it.deleted && it.accountId == id }
.mapNotNull { e -> expenseRow(e)?.let { Triple<Pair<String, String>, LedgerRow, Long>(e.date to e.createdAt, it, e.amountOre) } }
// TODO(spec): §14.2.4 does not say whether a settlement row in an account list carries the settlement's total
// or its allocation to that account; the fakes show the total, with the account count, as AC-11.7 describes.
val settlementRows = settlements.filter { !it.deleted && id in it.allocations }
.mapNotNull { s -> settlementRow(s)?.let { Triple<Pair<String, String>, LedgerRow, Long>(s.date to s.createdAt, it, 0L) } }
return (expenseRows + settlementRows)
.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }
.groupBy { it.first.first.take(7) }
.map { (period, rows) -> LedgerMonth(period, rows.sumOf { it.third }, rows.map { it.second }) }
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.fakes
/** One fake of every repository interface of SPEC §14.2.4, all backed by the same [store] and [scenario]. */
class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
val store = FakeStore(scenario)
val session = FakeSessionRepository(store)
val home = FakeHomeRepository(store)
val accounts = FakeAccountRepository(store)
val expenses = FakeExpenseRepository(store)
val settlements = FakeSettlementRepository(store)
val analytics = FakeAnalyticsRepository(store)
val profile = FakeProfileRepository(store)
val categories = FakeCategoryRepository(store)
val recurring = FakeRecurringRepository(store)
val devices = FakeDeviceRepository(store)
val invites = FakeInviteRepository(store)
val sync = FakeSyncRepository(store)
val update = FakeUpdateRepository(store)
val about = FakeAboutRepository(store)
val connectivity = FakeConnectivityRepository(store)
}
@@ -0,0 +1,92 @@
package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/** An expense as the fakes hold it: the stored row of SPEC §14.2.1 in model types. */
data class FakeExpense(
val id: ExpenseId,
val accountId: AccountId,
val categoryId: CategoryId,
val amountOre: Long,
val description: String,
val notes: String,
val date: String,
val payer: UserId,
val mode: SplitMode,
val shares: List<Share>,
val createdAt: String,
val recurringTemplateId: TemplateId? = null,
val deleted: Boolean = false,
val pending: Boolean = false,
val timeline: List<TimelineLine> = emptyList(),
)
/** A settlement as the fakes hold it; allocations in their stored order. */
data class FakeSettlement(
val id: SettlementId,
val from: UserId,
val to: UserId,
val amountOre: Long,
val date: String,
val note: String,
val allocations: Map<AccountId, Long>,
val createdAt: String,
val deleted: Boolean = false,
val pending: Boolean = false,
val timeline: List<TimelineLine> = emptyList(),
)
/**
* Everything the fakes know. A scenario is plain data; the fake repositories derive every `*State` from it with the
* rules in `:domain`, so fakes never disagree with the real rules (SPEC §6.2).
*
* @property me the viewer; [partner] is null until the partner has joined (AC-5.1).
* @property today the phone's local date (`YYYY-MM-DD`); [now] the phone's clock in the fixed RFC 3339 form.
* @property defaultAccountId the account last used on this phone (AC-8.6); [lastCategoryFor] the category last used
* per account on this phone.
*/
data class FakeScenario(
val me: User,
val partner: User?,
val deviceId: DeviceId,
val session: SessionState,
val accounts: List<Account>,
val categories: List<Category>,
val expenses: List<FakeExpense>,
val settlements: List<FakeSettlement>,
val templates: List<RecurringTemplate>,
val devices: List<Device>,
val invites: List<Invite>,
val rejections: List<Rejection>,
val lastSuccessAt: String?,
val clockSkew: Boolean,
val syncRunning: Boolean,
val update: UpdateState,
val about: AboutState,
val online: Boolean,
val today: String,
val now: String,
val defaultAccountId: AccountId?,
val lastCategoryFor: Map<AccountId, CategoryId>,
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
@@ -0,0 +1,77 @@
package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/** Shared scenarios. Feature scenarios live in `fakes/<feature>/` (SPEC §11.3). */
object FakeScenarios {
val owner = User(UserId("01920000-0000-7000-8000-0000000000a1"), Role.ADMIN, "Jacob", null)
val partner = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna", null)
private val ownerDevice = DeviceId("01920000-0000-7000-8000-0000000000d1")
/** The ten seeded default categories (SPEC AC-7.2, ids of §14.1). */
val defaultCategories: List<Category> = listOf(
Triple(DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN),
Triple(DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE),
Triple(DefaultCategoryKey.HOME, "home", ColorToken.BROWN),
Triple(DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW),
Triple(DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE),
Triple(DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL),
Triple(DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE),
Triple(DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED),
Triple(DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK),
Triple(DefaultCategoryKey.OTHER, "category", ColorToken.GREY),
).mapIndexed { index, (key, icon, color) ->
Category(
id = CategoryId("01920000-0000-7000-8000-" + (index + 1).toString(16).padStart(12, '0')),
defaultKey = key,
customName = null,
icon = icon,
color = color,
sortOrder = (index + 1) * 10,
archived = false,
)
}
/**
* The empty scenario: the household is complete (owner and partner, no pictures), online, synced, no accounts, the
* ten default categories only, no expenses, settlements, templates, invites or rejections. Every required field
* of every `*State` is satisfied.
*/
val empty = FakeScenario(
me = owner,
partner = partner,
deviceId = ownerDevice,
session = SessionState.Paired(owner, partner, ownerDevice),
accounts = emptyList(),
categories = defaultCategories,
expenses = emptyList(),
settlements = emptyList(),
templates = emptyList(),
devices = listOf(
Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, current = true, revoked = false),
),
invites = emptyList(),
rejections = emptyList(),
lastSuccessAt = "2026-10-17T06:00:00.000Z",
clockSkew = false,
syncRunning = false,
update = UpdateState.None,
about = AboutState("dev", 1, "https://icehack.asuscomm.com:8443", null),
online = true,
today = "2026-10-17",
now = "2026-10-17T06:00:00.000Z",
defaultAccountId = null,
lastCategoryFor = emptyMap(),
)
}
@@ -0,0 +1,292 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryIcons
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncStatusState
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
class FakeProfileRepository(private val store: FakeStore) : ProfileRepository {
override fun household(): Flow<List<User>> = store.derive { it.users }
override suspend fun setDisplayName(name: String): OnlineResult<Unit> = store.online {
if (name.codePointLength() !in 1..40) return@online OnlineResult.Failed("validation_failed", "display_name")
store.editMe { it.copy(displayName = name.trim()) }
OnlineResult.Ok(Unit)
}
override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit> = store.online {
if (mimeType !in setOf("image/jpeg", "image/png", "image/webp")) return@online OnlineResult.Failed("unsupported_media_type", null)
if (bytes.size > 5 * 1024 * 1024) return@online OnlineResult.Failed("payload_too_large", null)
store.editMe { it.copy(pictureVersion = (it.pictureVersion ?: 0) + 1) }
OnlineResult.Ok(Unit)
}
override suspend fun removePicture(): OnlineResult<Unit> = store.online {
store.editMe { it.copy(pictureVersion = null) }
OnlineResult.Ok(Unit)
}
}
private fun FakeStore.editMe(change: (User) -> User) = edit { s ->
val me = change(s.me)
s.copy(me = me, devices = s.devices.map { d -> if (d.user.id == me.id) d.copy(user = me) else d })
}
class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository {
override fun all(): Flow<List<Category>> = store.derive { s -> s.categories.sortedBy { it.sortOrder } }
override suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult<CategoryId> = store.online { s ->
validateCategory(name, icon) ?: run {
val id = CategoryId(store.newId())
val sortOrder = (s.categories.maxOfOrNull { it.sortOrder } ?: 0) + 10 // AC-7.3
store.edit { it.copy(categories = it.categories + Category(id, null, name.trim(), icon, color, sortOrder, archived = false)) }
OnlineResult.Ok(id)
}
}
override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult<Unit> = store.online { s ->
val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null)
if (name == null && category.defaultKey == null) return@online OnlineResult.Failed("category_is_default", "name")
validateCategory(name, icon) ?: run {
store.editCategory(id) { it.copy(customName = name?.trim(), icon = icon, color = color) }
OnlineResult.Ok(Unit)
}
}
override suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit> = store.online {
store.edit { s ->
s.copy(categories = s.categories.map { c -> idsInOrder.indexOf(c.id).takeIf { it >= 0 }?.let { c.copy(sortOrder = (it + 1) * 10) } ?: c })
}
OnlineResult.Ok(Unit)
}
override suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit> = store.online { s ->
if (s.category(id) == null) return@online OnlineResult.Failed("not_found", null)
store.editCategory(id) { it.copy(archived = archived) }
OnlineResult.Ok(Unit)
}
override suspend fun delete(id: CategoryId): OnlineResult<Unit> = store.online { s ->
val category = s.category(id) ?: return@online OnlineResult.Failed("not_found", null)
when {
category.defaultKey != null -> OnlineResult.Failed("category_is_default", null) // AC-7.6
s.expenses.any { !it.deleted && it.categoryId == id } || s.templates.any { it.draft.categoryId == id } ->
OnlineResult.Failed("category_in_use", null)
else -> {
store.edit { it.copy(categories = it.categories.filter { c -> c.id != id }) }
OnlineResult.Ok(Unit)
}
}
}
/** §9.6 and §9.7. A null name (reset of a default name) is valid. */
private fun validateCategory(name: String?, icon: String): OnlineResult.Failed? = when {
name != null && name.codePointLength() !in 1..40 -> OnlineResult.Failed("validation_failed", "name")
icon !in CategoryIcons.ALL -> OnlineResult.Failed("validation_failed", "icon")
else -> null
}
private fun FakeStore.editCategory(id: CategoryId, change: (Category) -> Category) =
edit { s -> s.copy(categories = s.categories.map { if (it.id == id) change(it) else it }) }
}
class FakeRecurringRepository(private val store: FakeStore) : RecurringRepository {
override fun all(): Flow<List<RecurringTemplate>> = store.derive { it.templates }
override suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId> = store.online { s ->
s.validateTemplate(t) ?: run {
val id = TemplateId(store.newId())
val created = t.copy(id = id, nextOccurrence = s.nextOccurrence(t))
store.edit { it.copy(templates = it.templates + created) }
OnlineResult.Ok(id)
}
}
override suspend fun update(t: RecurringTemplate): OnlineResult<Unit> = store.online { s ->
if (s.templates.none { it.id == t.id }) return@online OnlineResult.Failed("not_found", null)
s.validateTemplate(t) ?: run {
store.edit { it.copy(templates = it.templates.map { x -> if (x.id == t.id) t.copy(nextOccurrence = s.nextOccurrence(t)) else x }) }
OnlineResult.Ok(Unit)
}
}
override suspend fun delete(id: TemplateId): OnlineResult<Unit> = store.online { s ->
if (s.templates.none { it.id == id }) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(templates = it.templates.filter { x -> x.id != id }) }
OnlineResult.Ok(Unit)
}
}
/** §9.6 limits of a template (the split itself is checked like an expense's). */
private fun FakeScenario.validateTemplate(t: RecurringTemplate): OnlineResult.Failed? {
val period = Regex("""\d{4}-(0[1-9]|1[0-2])""")
val outcome = split(t.draft)
return when {
partner == null -> OnlineResult.Failed("household_incomplete", null)
t.dayOfMonth !in 1..31 -> OnlineResult.Failed("validation_failed", "day_of_month")
!period.matches(t.startPeriod) -> OnlineResult.Failed("validation_failed", "start_period")
t.endPeriod?.let { !period.matches(it) || it < t.startPeriod } == true -> OnlineResult.Failed("validation_failed", "end_period")
t.draft.description.codePointLength() !in 1..100 -> OnlineResult.Failed("validation_failed", "description")
t.draft.accountId?.let { account(it) } == null -> OnlineResult.Failed("validation_failed", "account_id")
t.draft.categoryId?.let { category(it) } == null -> OnlineResult.Failed("validation_failed", "category_id")
outcome !is SplitOutcome.Ok -> OnlineResult.Failed("validation_failed", "splits")
else -> null
}
}
/**
* The server's `next_occurrence_date` (§9.5) as the fakes see it: the clamped date of the first period from
* `start_period` whose date is today or later, within `end_period`; null when paused.
*/
private fun FakeScenario.nextOccurrence(t: RecurringTemplate): String? {
if (t.paused) return null
val today = LocalDate.parse(today)
var month = YearMonth.parse(t.startPeriod)
val end = t.endPeriod?.let { YearMonth.parse(it) }
while (end == null || month <= end) {
val first = LocalDate(month.year, month.month, 1)
val last = first.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY)
val date = LocalDate(month.year, month.month.number, minOf(t.dayOfMonth, last.day))
if (date >= today) return date.toString()
month = month.plus(1, DateTimeUnit.MONTH)
}
return null
}
class FakeDeviceRepository(private val store: FakeStore) : DeviceRepository {
/** AC-3.1: the admin sees all devices, a member their own. */
override suspend fun list(): OnlineResult<List<Device>> = store.online { s ->
OnlineResult.Ok(if (s.me.role == Role.ADMIN) s.devices else s.devices.filter { it.user.id == s.me.id })
}
override suspend fun revoke(id: DeviceId): OnlineResult<Unit> = store.online { s ->
val device = s.devices.firstOrNull { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
when {
device.current -> OnlineResult.Failed("cannot_revoke_current_device", null) // AC-3.2
s.me.role != Role.ADMIN && device.user.id != s.me.id -> OnlineResult.Failed("forbidden", null)
else -> {
store.edit { it.copy(devices = it.devices.map { d -> if (d.id == id) d.copy(revoked = true) else d }) }
OnlineResult.Ok(Unit)
}
}
}
}
class FakeInviteRepository(private val store: FakeStore) : InviteRepository {
override suspend fun list(): OnlineResult<List<Invite>> = store.online { s ->
OnlineResult.Ok(s.invites.map { it.copy(code = null, link = null) })
}
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = store.online { s ->
when {
s.me.role != Role.ADMIN -> OnlineResult.Failed("forbidden", null)
kind == InviteKind.ADMIN_SETUP -> OnlineResult.Failed("invalid_request", "kind")
kind == InviteKind.PARTNER && s.partner != null -> OnlineResult.Failed("household_full", null) // AC-2.1
kind == InviteKind.DEVICE && (targetUser == null || s.user(targetUser) == null) -> OnlineResult.Failed("not_found", "target_user_id")
else -> {
val id = store.newId()
val code = inviteCode(id)
// 72-hour expiry (AC-2.3): three calendar days later at the same UTC time.
val expires = LocalDate.parse(s.now.take(10)).plus(3, DateTimeUnit.DAY).toString() + s.now.drop(10)
val invite = Invite(InviteId(id), kind, targetUser.takeIf { kind == InviteKind.DEVICE }, code, "${s.about.serverUrl}/i/$code", s.now, expires)
store.edit { st ->
// AC-2.1: a new partner invite cancels any pending one.
val kept = if (kind == InviteKind.PARTNER) st.invites.filter { it.kind != InviteKind.PARTNER } else st.invites
st.copy(invites = kept + invite)
}
OnlineResult.Ok(invite)
}
}
}
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = store.online { s ->
if (s.invites.none { it.id == id }) return@online OnlineResult.Failed("not_found", null)
store.edit { it.copy(invites = it.invites.filter { x -> x.id != id }) }
OnlineResult.Ok(Unit)
}
/** 12 characters of the Crockford base32 alphabet (§8.3), derived from the id so screenshots are stable. */
private fun inviteCode(id: String): String {
val alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
var value = id.filter { it.isLetterOrDigit() }.takeLast(12).toLong(16)
return buildString { repeat(12) { append(alphabet[(value % 32).toInt()]); value /= 32 } }.reversed()
}
}
class FakeSyncRepository(private val store: FakeStore) : SyncRepository {
override fun status(): Flow<SyncStatusState> = store.derive { s ->
val rows: List<Pair<Pair<String, String>, LedgerRow?>> =
s.expenses.filter { it.pending }.map { (it.date to it.createdAt) to s.expenseRow(it) } +
s.settlements.filter { it.pending }.map { (it.date to it.createdAt) to s.settlementRow(it) }
SyncStatusState(
summary = s.syncSummary(),
pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second },
rejections = s.rejections,
)
}
/** A sync in the fakes: online, every pending change is accepted. */
override suspend fun syncNow() = store.edit { s ->
if (!s.online || s.clockSkew) {
s
} else {
s.copy(
expenses = s.expenses.map { it.copy(pending = false) },
settlements = s.settlements.map { it.copy(pending = false) },
lastSuccessAt = s.now,
)
}
}
override suspend fun dismiss(mutationId: String) = store.edit { s -> s.copy(rejections = s.rejections.filter { it.mutationId != mutationId }) }
}
/** The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update, AC-16.5). */
class FakeUpdateRepository(store: FakeStore) : UpdateRepository {
override val state: Flow<UpdateState> = store.derive { it.update }
override suspend fun check() = Unit
override suspend fun install() = Unit
}
class FakeAboutRepository(store: FakeStore) : AboutRepository {
private val flow = store.derive { it.about }
override fun about(): Flow<AboutState> = flow
}
class FakeConnectivityRepository(store: FakeStore) : ConnectivityRepository {
override val online: Flow<Boolean> = store.derive { it.online }
}
@@ -0,0 +1,134 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.domain.overSettledAccounts
import se.hagstedt.kvitt.domain.proposeAllocation
import se.hagstedt.kvitt.domain.validateSettlement
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AllocationLine
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SettleRequest
import se.hagstedt.kvitt.model.SettleState
import se.hagstedt.kvitt.model.SettlementDetailState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.SettlementRepository
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.money.AccountPosition
import se.hagstedt.kvitt.model.money.Allocation
import se.hagstedt.kvitt.model.money.AllocationResult
import se.hagstedt.kvitt.model.money.SettlementError
import kotlin.math.absoluteValue
class FakeSettlementRepository(private val store: FakeStore) : SettlementRepository {
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
val s = store.current
val partner = requireNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
val existing = editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
if (existing != null) {
return s.settleState(
SettleRequest(existing.id, existing.from, existing.amountOre, fixedAccount, existing.allocations, existing.date, existing.note),
)
}
// AC-11.3 / AC-11.4: the user who owes pays; the amount is the absolute balance, empty when it is zero.
val balances = s.balances()
val myNet = if (fixedAccount != null) balances.net(fixedAccount, s.me.id) else balances.globalNet(s.me.id)
val payer = if (myNet > 0) partner.id else s.me.id
val amount = myNet.absoluteValue.takeIf { it > 0 }
return s.settleState(SettleRequest(null, payer, amount, fixedAccount, null, s.today, ""))
}
override suspend fun recompute(request: SettleRequest): SettleState = store.current.settleState(request)
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
val s = store.current
val state = s.settleState(request)
val errors = mutableListOf<FieldError>()
val amount = request.amountOre
if (amount == null) {
errors += FieldError("amountOre", "required")
} else {
validateSettlement(amount, state.payer.id.value, state.recipient.id.value, s.users.map { it.id.value }, state.allocations())?.let {
errors += FieldError(if (it == SettlementError.AMOUNT_OUT_OF_RANGE) "amountOre" else "allocations", it.name.lowercase())
}
}
// TODO(spec): no domain token for the §9.6 note and date limits; the fakes use `validation_failed` (as expenses).
if (request.note.codePointLength() > 200) errors += FieldError("note", "validation_failed")
if (!s.validDate(request.date)) errors += FieldError("date", "validation_failed")
if (errors.isNotEmpty() || amount == null) return SaveResult.Invalid(errors)
val existing = request.editing?.let { id -> s.settlements.firstOrNull { it.id == id } }
val id = request.editing ?: SettlementId(store.newId())
val saved = FakeSettlement(
id = id,
from = state.payer.id,
to = state.recipient.id,
amountOre = amount,
date = request.date,
note = request.note.trim(),
allocations = state.lines.associate { it.account.id to it.amountOre },
createdAt = existing?.createdAt ?: s.now,
pending = true,
timeline = existing?.timeline.orEmpty(),
)
store.edit { it.copy(settlements = it.settlements.filter { x -> x.id != id } + saved) }
return SaveResult.Saved(id)
}
override suspend fun delete(id: SettlementId) = store.edit { s ->
s.copy(settlements = s.settlements.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
}
override fun detail(id: SettlementId): Flow<SettlementDetailState?> = store.derive { s ->
val st = s.settlements.firstOrNull { it.id == id } ?: return@derive null
val from = s.user(st.from) ?: return@derive null
val to = s.user(st.to) ?: return@derive null
SettlementDetailState(
id = st.id,
from = from,
to = to,
amountOre = st.amountOre,
date = st.date,
note = st.note,
allocations = st.allocations.mapNotNull { (account, amount) -> s.account(account)?.let { it to amount } },
deleted = st.deleted,
pending = st.pending,
timeline = s.timelineWithPending(st.timeline, st.pending, st.deleted),
)
}
}
private fun SettleState.allocations(): List<Allocation> = lines.map { Allocation(it.account.id.value, it.amountOre) }
/**
* §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows;
* a per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6).
*/
private fun FakeScenario.settleState(request: SettleRequest): SettleState {
val payer: User = requireNotNull(user(request.payer)) { "unknown payer" }
val recipient: User = requireNotNull(users.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" }
val balances = balances(excludingSettlement = request.editing?.value)
val positions = accountsInOrder().map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id, request.payer)) }
val amount = request.amountOre
val fixed = request.fixedAccount
val given = request.allocations
val rows: List<Pair<AccountId, Long>> = when {
fixed != null -> listOf(fixed to (given?.get(fixed) ?: amount ?: 0L))
given != null -> given.toList()
amount == null -> emptyList()
else -> when (val proposal = proposeAllocation(amount, positions)) {
is AllocationResult.Ok -> proposal.allocations.map { AccountId(it.accountId) to it.amountOre }
is AllocationResult.Err -> emptyList()
}
}.mapNotNull { (id, value) -> id.takeIf { account(it) != null }?.let { it to value } }
val over = overSettledAccounts(rows.map { Allocation(it.first.value, it.second) }, positions).toSet()
val lines = rows.mapNotNull { (id, value) ->
account(id)?.let { AllocationLine(it, balances.net(id, request.payer), value, id.value in over) }
}
val allocated = lines.sumOf { it.amountOre }
val valid = amount != null && validateSettlement(
amount, payer.id.value, recipient.id.value, users.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) },
) == null
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note)
}
@@ -0,0 +1,123 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import se.hagstedt.kvitt.domain.computeBalances
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.AuditSource
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.model.money.BalanceExpense
import se.hagstedt.kvitt.model.money.BalanceSettlement
import se.hagstedt.kvitt.model.money.Balances
/**
* The single mutable state behind all fake repositories of one [FakeRepositories]. Writes go through [edit], so every
* flow re-emits, like Room after a local write (AC-10.4).
*/
class FakeStore(initial: FakeScenario) {
private val mutable = MutableStateFlow(initial)
val state: StateFlow<FakeScenario> = mutable
val current: FakeScenario get() = mutable.value
private var nextId = 0x100
fun edit(transform: (FakeScenario) -> FakeScenario) = mutable.update(transform)
fun <T> derive(transform: (FakeScenario) -> T): Flow<T> = mutable.map(transform)
/** A new UUIDv7-shaped id, deterministic so screenshots are stable. */
fun newId(): String {
nextId += 1
return "01920000-0000-7000-8000-" + nextId.toString(16).padStart(12, '0')
}
/** §9.9: online-only actions report [OnlineResult.Offline] when the scenario is offline. */
inline fun <T> online(action: (FakeScenario) -> OnlineResult<T>): OnlineResult<T> =
if (!current.online) OnlineResult.Offline else action(current)
}
// Derived values shared by the fakes. Each one is the rule of the spec section named, computed with :domain.
internal fun FakeScenario.user(id: UserId): User? = users.firstOrNull { it.id == id }
internal fun FakeScenario.account(id: AccountId): Account? = accounts.firstOrNull { it.id == id }
internal fun FakeScenario.category(id: CategoryId): Category? = categories.firstOrNull { it.id == id }
/** AC-6.6: accounts by `created_at`, then id. */
internal fun FakeScenario.accountsInOrder(): List<Account> = accounts.sortedWith(compareBy({ it.createdAt }, { it.id.value }))
/** §9.3 over non-deleted rows, optionally excluding the settlement being edited (AC-11.6). */
internal fun FakeScenario.balances(excludingSettlement: String? = null): Balances = computeBalances(
userIds = users.map { it.id.value },
accountIds = accounts.map { it.id.value },
expenses = expenses.filter { !it.deleted }.map { e ->
BalanceExpense(e.accountId.value, e.amountOre, e.payer.value, e.shares.associate { it.userId.value to it.shareOre })
},
settlements = settlements.filter { !it.deleted && it.id.value != excludingSettlement }.map { s ->
BalanceSettlement(s.from.value, s.to.value, s.allocations.mapKeys { it.key.value })
},
)
internal fun Balances.net(account: AccountId, user: UserId): Long = perAccount[account.value]?.get(user.value) ?: 0L
internal fun Balances.globalNet(user: UserId): Long = global[user.value] ?: 0L
/** §9.2: the effect line of an expense from the viewer's side. */
internal fun FakeExpense.effectFor(viewer: UserId): ExpenseEffect {
val nonPayerShare = shares.firstOrNull { it.userId != payer }?.shareOre ?: 0L
return when {
nonPayerShare == 0L -> if (viewer == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(shares.firstOrNull { it.userId == viewer }?.shareOre ?: 0L)
}
}
internal fun FakeScenario.expenseRow(e: FakeExpense): LedgerRow.ExpenseRow? {
val category = category(e.categoryId) ?: return null
return LedgerRow.ExpenseRow(e.id, e.date, e.description, category, e.amountOre, e.effectFor(me.id), e.pending)
}
internal fun FakeScenario.settlementRow(s: FakeSettlement): LedgerRow.SettlementRow? {
val from = user(s.from) ?: return null
val to = user(s.to) ?: return null
return LedgerRow.SettlementRow(s.id, s.date, from, to, s.amountOre, s.allocations.size, s.pending)
}
/** AC-8.10: date descending, then `created_at` descending. */
internal val ledgerOrder: Comparator<Pair<String, String>> =
compareByDescending<Pair<String, String>> { it.first }.thenByDescending { it.second }
/** AC-12.5: a pending local change ends the timeline with a line marked [TimelineLine.pendingLocal]. */
internal fun FakeScenario.timelineWithPending(timeline: List<TimelineLine>, pending: Boolean, deleted: Boolean): List<TimelineLine> {
if (!pending) return timeline
val action = when {
deleted -> AuditAction.DELETE
timeline.isEmpty() -> AuditAction.CREATE
else -> AuditAction.UPDATE
}
return timeline + TimelineLine(me, action, AuditSource.APP, now, emptyList(), pendingLocal = true)
}
/** Length in Unicode code points after trimming (§9.6). */
internal fun String.codePointLength(): Int = trim().let { it.codePointCount(0, it.length) }
/** §9.6: a valid date from 2000-01-01 to today + 365 days. */
internal fun FakeScenario.validDate(date: String): Boolean {
val parsed = runCatching { LocalDate.parse(date) }.getOrNull() ?: return false
return parsed >= LocalDate(2000, 1, 1) && parsed <= LocalDate.parse(today).plus(365, DateTimeUnit.DAY)
}
@@ -0,0 +1,84 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.UpdateState
/** Every fake repository answers from the empty scenario with a complete, empty state. */
class EmptyScenarioTest {
private val fakes = FakeRepositories()
@Test
fun sessionIsPairedWithTheWholeHousehold() = runTest {
val session = fakes.session.session.first()
assertTrue(session is SessionState.Paired)
assertEquals(listOf(FakeScenarios.owner, FakeScenarios.partner), fakes.profile.household().first())
}
@Test
fun homeIsSettledWithNoAccounts() = runTest {
val home = fakes.home.home().first()
assertEquals(0L, home.global?.netOre)
assertTrue(home.active.isEmpty() && home.archived.isEmpty())
assertEquals(0, home.sync.pendingCount)
assertNull(home.update)
}
@Test
fun accountsExpensesAndSettlementsAreEmpty() = runTest {
assertTrue(fakes.accounts.all().first().isEmpty())
assertNull(fakes.accounts.detail(AccountId("none")).first())
assertNull(fakes.expenses.detail(ExpenseId("none")).first())
assertNull(fakes.settlements.detail(SettlementId("none")).first())
val options = fakes.expenses.editorOptions().first()
assertNull(options.defaultAccountId)
assertEquals(10, options.categories.size)
val settle = fakes.settlements.start(null, null)
assertNull(settle.amountOre)
assertTrue(settle.lines.isEmpty())
}
@Test
fun analyticsHasEmptyBucketsForTheDefaultRange() = runTest {
val filter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
val state = fakes.analytics.analytics(filter).first()
assertEquals("2026-05-01", state.start)
assertEquals(6, state.buckets.size)
assertEquals(0L, state.totalOre)
}
@Test
fun settingsRepositoriesAnswer() = runTest {
assertEquals(10, fakes.categories.all().first().size)
assertTrue(fakes.recurring.all().first().isEmpty())
assertEquals(1, (fakes.devices.list() as OnlineResult.Ok).value.size)
assertTrue((fakes.invites.list() as OnlineResult.Ok).value.isEmpty())
assertTrue(fakes.sync.status().first().rejections.isEmpty())
assertEquals(UpdateState.None, fakes.update.state.first())
assertEquals("dev", fakes.about.about().first().versionName)
assertTrue(fakes.connectivity.online.first())
}
@Test
fun createdAccountFlowsIntoHomeWithZeroBalance() = runTest {
val shares = mapOf(FakeScenarios.owner.id to 5000, FakeScenarios.partner.id to 5000)
val created = fakes.accounts.create("Hemma", shares)
assertTrue(created is OnlineResult.Ok)
assertEquals(OnlineResult.Failed("name_taken", "name"), fakes.accounts.create(" hemma ", shares))
assertEquals(listOf("Hemma"), fakes.home.home().first().active.map { it.account.name })
}
}
+9
View File
@@ -0,0 +1,9 @@
// :core:model — the UI contract (SPEC §14.2.4) and the domain types (SPEC §14.5.1, §14.5.4). Pure Kotlin/JVM.
plugins {
id("kvitt.jvm.library")
}
dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
}
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Accounts (S-5, S-6), SPEC §14.2.4.
data class AccountDetailState(val account: Account, val balance: BalanceView, val months: List<LedgerMonth>, val deletable: Boolean, val users: List<User>)
interface AccountRepository {
fun detail(id: AccountId): Flow<AccountDetailState?>
fun all(): Flow<List<Account>>
suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId>
suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit>
suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit>
suspend fun delete(id: AccountId): OnlineResult<Unit>
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Analytics (S-11, S-12), SPEC §14.2.4.
data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy)
/** label null: UI uses the default category name. */
data class GroupKey(val id: String, val label: String?, val color: ColorToken?)
data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map<String, Long>, val totalOre: Long)
data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List<GroupKey>, val buckets: List<SpendBucket>, val byCategory: List<Pair<GroupKey, Long>>, val byAccount: List<Pair<GroupKey, Long>>, val totalOre: Long)
data class DrillDownState(val title: String, val totalOre: Long, val rows: List<LedgerRow.ExpenseRow>)
interface AnalyticsRepository {
fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState>
fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState>
}
@@ -0,0 +1,39 @@
package se.hagstedt.kvitt.model
// Enums (SPEC §14.2.4). Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default").
enum class Role { ADMIN, MEMBER }
enum class SplitMode { DEFAULT, RATIO, EXACT, FULL }
enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE }
enum class EntityType { EXPENSE, SETTLEMENT }
enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER }
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
enum class Granularity { MONTH, WEEK }
enum class Measure { HOUSEHOLD, MY_SHARE }
enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
/** Category icon tokens: Material Symbols names, the exhaustive v1 set of SPEC §9.7, in that order. */
object CategoryIcons {
val ALL: List<String> = listOf(
"shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train",
"local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center",
"medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard",
"celebration", "build", "chair", "subscriptions", "category",
)
}
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Expenses (S-7, S-8), SPEC §14.2.4.
data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map<UserId, Int>?, val exactOre: Map<UserId, Long>?, val fullOn: UserId?)
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String)
data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List<FieldChange>, val pendingLocal: Boolean)
/** Ids already resolved to names; amounts and dates raw (öre as String, ISO date), formatted by the UI. */
data class FieldChange(val field: String, val oldDisplay: String?, val newDisplay: String?)
data class ExpenseDetailState(val id: ExpenseId, val account: Account, val category: Category, val amountOre: Long, val description: String, val notes: String, val date: String, val payer: User, val mode: SplitMode, val shares: List<Share>, val recurringTemplateName: String?, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface ExpenseRepository {
fun editorOptions(): Flow<ExpenseEditorOptions>
suspend fun load(id: ExpenseId): ExpenseDraft?
fun preview(draft: ExpenseDraft): List<Share>? // null while the draft cannot be computed
suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
suspend fun delete(id: ExpenseId)
fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
}
@@ -0,0 +1,18 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Balances shared by several screens and Home (S-4), SPEC §14.2.4.
/** netOre is the viewer's net (§9.3): > 0 partner owes viewer. */
data class BalanceView(val netOre: Long, val partner: User)
data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int)
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean)
data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List<AccountSummary>, val archived: List<AccountSummary>, val sync: SyncSummary, val update: UpdateInfo?)
interface HomeRepository {
fun home(): Flow<HomeState>
}
@@ -0,0 +1,19 @@
package se.hagstedt.kvitt.model
// Ids (SPEC §14.2.4). Values are UUIDv7 strings on the wire.
@JvmInline value class UserId(val value: String)
@JvmInline value class AccountId(val value: String)
@JvmInline value class CategoryId(val value: String)
@JvmInline value class ExpenseId(val value: String)
@JvmInline value class SettlementId(val value: String)
@JvmInline value class TemplateId(val value: String)
@JvmInline value class DeviceId(val value: String)
@JvmInline value class InviteId(val value: String)
@@ -0,0 +1,17 @@
package se.hagstedt.kvitt.model
// Ledger rows (S-5, S-12), SPEC §14.2.4.
sealed interface ExpenseEffect {
data class Lent(val ore: Long) : ExpenseEffect
data class Borrowed(val ore: Long) : ExpenseEffect
data object NotInvolved : ExpenseEffect
data object PaidForYourself : ExpenseEffect
}
sealed interface LedgerRow {
data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow
data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow
}
data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List<LedgerRow>)
@@ -0,0 +1,25 @@
package se.hagstedt.kvitt.model
// Plain records and results (SPEC §14.2.4).
data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?)
data class Account(val id: AccountId, val name: String, val defaultShares: Map<UserId, Int>, val createdAt: String, val archived: Boolean)
data class Category(val id: CategoryId, val defaultKey: DefaultCategoryKey?, val customName: String?, val icon: String, val color: ColorToken, val sortOrder: Int, val archived: Boolean)
data class Share(val userId: UserId, val shareOre: Long, val shareBp: Int?)
sealed interface OnlineResult<out T> {
data class Ok<T>(val value: T) : OnlineResult<T>
data object Offline : OnlineResult<Nothing>
data class Failed(val code: String, val field: String?) : OnlineResult<Nothing> // code from §14.3.2
}
sealed interface SaveResult<out T> {
data class Saved<T>(val id: T) : SaveResult<T>
data class Invalid(val errors: List<FieldError>) : SaveResult<Nothing>
}
/** `error` is a domain error token of §14.5.3 or "required". */
data class FieldError(val field: String, val error: String)
@@ -0,0 +1,18 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Session and onboarding (S-1, S-2, S-3, S-18), SPEC §14.2.4.
sealed interface SessionState {
data object Unpaired : SessionState
data class Paired(val me: User, val partner: User?, val deviceId: DeviceId) : SessionState
data object Revoked : SessionState
data object Unrecognised : SessionState // 401 unauthorized: pair again, data kept (§14.4.8)
}
interface SessionRepository {
val session: Flow<SessionState>
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User>
suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired
}
@@ -0,0 +1,46 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Settings (S-13 to S-15, S-19 to S-22), SPEC §14.2.4.
interface ProfileRepository {
fun household(): Flow<List<User>>
suspend fun setDisplayName(name: String): OnlineResult<Unit>
suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit>
suspend fun removePicture(): OnlineResult<Unit>
}
interface CategoryRepository {
fun all(): Flow<List<Category>>
suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult<CategoryId>
suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult<Unit> // name null = reset default name
suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit>
suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit>
suspend fun delete(id: CategoryId): OnlineResult<Unit>
}
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
interface RecurringRepository {
fun all(): Flow<List<RecurringTemplate>>
suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId>
suspend fun update(t: RecurringTemplate): OnlineResult<Unit>
suspend fun delete(id: TemplateId): OnlineResult<Unit>
}
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val current: Boolean, val revoked: Boolean)
interface DeviceRepository {
suspend fun list(): OnlineResult<List<Device>>
suspend fun revoke(id: DeviceId): OnlineResult<Unit>
}
/** code and link only right after creation. */
data class Invite(val id: InviteId, val kind: InviteKind, val targetUser: UserId?, val code: String?, val link: String?, val createdAt: String, val expiresAt: String)
interface InviteRepository {
suspend fun list(): OnlineResult<List<Invite>>
suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite>
suspend fun cancel(id: InviteId): OnlineResult<Unit>
}
@@ -0,0 +1,21 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Settlements (S-9, S-10), SPEC §14.2.4.
data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean)
data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List<AllocationLine>, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String)
data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map<AccountId, Long>?, val date: String, val note: String)
data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List<Pair<Account, Long>>, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface SettlementRepository {
suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState // defaults per AC-11.3 / AC-11.4
suspend fun recompute(request: SettleRequest): SettleState // allocations == null: propose (§9.4); else validate and warn
suspend fun save(request: SettleRequest): SaveResult<SettlementId>
suspend fun delete(id: SettlementId)
fun detail(id: SettlementId): Flow<SettlementDetailState?>
}
@@ -0,0 +1,42 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Sync status, update, about and connectivity (S-16, S-17, S-22, §9.9), SPEC §14.2.4.
data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String)
data class SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
interface SyncRepository {
fun status(): Flow<SyncStatusState>
suspend fun syncNow()
suspend fun dismiss(mutationId: String)
}
data class UpdateInfo(val versionCode: Int, val versionName: String, val sizeBytes: Long)
sealed interface UpdateState {
data object None : UpdateState
data class Available(val info: UpdateInfo) : UpdateState
data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState
data class Failed(val info: UpdateInfo) : UpdateState
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
interface UpdateRepository {
val state: Flow<UpdateState>
suspend fun check()
suspend fun install()
}
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?)
interface AboutRepository {
fun about(): Flow<AboutState>
}
/** §9.9 */
interface ConnectivityRepository {
val online: Flow<Boolean>
}
@@ -0,0 +1,22 @@
package se.hagstedt.kvitt.model.analytics
import kotlinx.datetime.LocalDate
// Analytics input and output types (SPEC §14.5.4), package per the orchestrator's M2b decision.
data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map<String, Long>)
data class DateRange(val start: LocalDate, val endInclusive: LocalDate)
enum class AnalyticsGranularity { MONTH, WEEK }
sealed interface AnalyticsMeasure {
data object Household : AnalyticsMeasure
data class Share(val userId: String) : AnalyticsMeasure
}
enum class AnalyticsGroupBy { CATEGORY, ACCOUNT }
data class Bucket(val key: String, val start: LocalDate, val endInclusive: LocalDate, val totals: Map<String, Long>, val total: Long)
data class SpendSeries(val buckets: List<Bucket>, val groupTotals: Map<String, Long>, val grandTotal: Long)
@@ -0,0 +1,16 @@
package se.hagstedt.kvitt.model.money
// SPEC §14.5.1
data class AccountPosition(val accountId: String, val createdAt: String, val archived: Boolean, val payerNetOre: Long)
enum class AllocationError { AMOUNT_OUT_OF_RANGE, NO_ACCOUNTS }
data class Allocation(val accountId: String, val amountOre: Long)
sealed interface AllocationResult {
data class Ok(val allocations: List<Allocation>, val excessOre: Long, val overSettledAccountIds: List<String>) : AllocationResult
data class Err(val error: AllocationError) : AllocationResult
}
enum class SettlementError { AMOUNT_OUT_OF_RANGE, SAME_USER, USER_UNKNOWN, NO_ALLOCATIONS, TOO_MANY_ALLOCATIONS, ALLOCATION_NOT_POSITIVE, DUPLICATE_ACCOUNT, ALLOCATION_SUM_MISMATCH }
@@ -0,0 +1,11 @@
package se.hagstedt.kvitt.model.money
// SPEC §14.5.1
data class BalanceExpense(val accountId: String, val amountOre: Long, val payerUserId: String, val shares: Map<String, Long>)
/** allocations: accountId -> öre. */
data class BalanceSettlement(val fromUserId: String, val toUserId: String, val allocations: Map<String, Long>)
/** perAccount: accountId -> userId -> net; global: userId -> net. */
data class Balances(val perAccount: Map<String, Map<String, Long>>, val global: Map<String, Long>)
@@ -0,0 +1,17 @@
package se.hagstedt.kvitt.model.money
// Domain input and output types (SPEC §14.5.1). Ids are plain strings; money is öre (Long); bp is Int.
sealed interface SplitSpec {
data class Ratio(val bp: Map<String, Int>) : SplitSpec // modes default and ratio
data class Exact(val ore: Map<String, Long>) : SplitSpec // mode exact
data class Full(val bearerUserId: String) : SplitSpec // mode full
}
/** Checked in this order; the first failure is returned (§14.5.2). Wire token = lowercase name (§14.5.3). */
enum class SplitError { AMOUNT_OUT_OF_RANGE, USERS_INVALID, PAYER_UNKNOWN, BP_OUT_OF_RANGE, BP_SUM_NOT_10000, EXACT_NEGATIVE, EXACT_SUM_MISMATCH, BEARER_UNKNOWN, SHARE_USERS_MISMATCH }
sealed interface SplitResult {
data class Ok(val shares: Map<String, Long>) : SplitResult
data class Err(val error: SplitError) : SplitResult
}
@@ -0,0 +1,71 @@
package se.hagstedt.kvitt.model
import org.junit.Assert.assertEquals
import org.junit.Test
import se.hagstedt.kvitt.model.money.AllocationError
import se.hagstedt.kvitt.model.money.SettlementError
import se.hagstedt.kvitt.model.money.SplitError
/** Wire values (lowercase enum names, SPEC §14.2.4, §14.5.3) and the §9.7 token lists. */
class ContractTest {
private fun wire(values: Array<out Enum<*>>) = values.map { it.name.lowercase() }
@Test
fun enumWireValuesMatchTheSpec() {
assertEquals(listOf("admin", "member"), wire(Role.entries.toTypedArray()))
assertEquals(listOf("default", "ratio", "exact", "full"), wire(SplitMode.entries.toTypedArray()))
assertEquals(listOf("admin_setup", "partner", "device"), wire(InviteKind.entries.toTypedArray()))
assertEquals(listOf("expense", "settlement"), wire(EntityType.entries.toTypedArray()))
assertEquals(listOf("create", "update", "delete"), wire(AuditAction.entries.toTypedArray()))
assertEquals(listOf("app", "recurring", "import"), wire(AuditSource.entries.toTypedArray()))
assertEquals(
listOf("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other"),
wire(DefaultCategoryKey.entries.toTypedArray()),
)
assertEquals(
listOf("green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"),
wire(ColorToken.entries.toTypedArray()),
)
assertEquals(
listOf("this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"),
wire(RangePreset.entries.toTypedArray()),
)
assertEquals(listOf("month", "week"), wire(Granularity.entries.toTypedArray()))
assertEquals(listOf("household", "my_share"), wire(Measure.entries.toTypedArray()))
assertEquals(listOf("category", "account"), wire(GroupBy.entries.toTypedArray()))
assertEquals(
listOf("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew"),
wire(SyncRejectionReason.entries.toTypedArray()),
)
}
@Test
fun domainErrorTokensMatchSpec14_5_3() {
assertEquals(
listOf(
"amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000",
"exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch",
),
wire(SplitError.entries.toTypedArray()),
)
assertEquals(listOf("amount_out_of_range", "no_accounts"), wire(AllocationError.entries.toTypedArray()))
assertEquals(
listOf(
"amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations",
"allocation_not_positive", "duplicate_account", "allocation_sum_mismatch",
),
wire(SettlementError.entries.toTypedArray()),
)
}
@Test
fun categoryIconsAreTheThirtyTokensOfSpec9_7InOrder() {
assertEquals(30, CategoryIcons.ALL.size)
assertEquals(30, CategoryIcons.ALL.toSet().size)
assertEquals("shopping_cart", CategoryIcons.ALL.first())
assertEquals("category", CategoryIcons.ALL.last())
// The ten default categories (AC-7.2) use icons from the set.
listOf("shopping_cart", "restaurant", "home", "bolt", "directions_car", "flight", "movie", "medical_services", "shopping_bag", "category")
.forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) }
}
}