M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)

- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account,
  payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10);
  without one it opens the current version as before. Ledger rows take a null onClick (read-only).
- AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback.
  S-22 shows "1.1.0 (schema 3)".
- SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no
  checkNotNull/requireNotNull in :data.
- :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark,
  200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200).
- Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a
  partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:32:24 +02:00
parent 7fb86f5524
commit dc7a296d70
90 changed files with 550 additions and 70 deletions
@@ -55,6 +55,10 @@ class KvittApi(
suspend fun acceptInvite(baseUrl: String, request: AcceptInviteRequest): ApiResult<AcceptInviteResponse> =
exchange(ApiTarget(baseUrl, null), HttpMethod.Post, "/invites/accept", authenticated = false) { jsonBody(request) }
// E-1: unauthenticated, to the paired server (S-22, D-110).
suspend fun health(): ApiResult<HealthDto> =
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/health", authenticated = false) }
// E-5, E-6: unauthenticated.
suspend fun apkLatest(): ApiResult<ApkLatestDto> =
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/apk/latest", authenticated = false) }
@@ -275,8 +275,8 @@ object DataModule {
SyncRepositoryImpl(local, db, summaries, engine, work)
@Provides @Singleton
fun about(credentials: CredentialStore, session: SessionManager, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
fun about(credentials: CredentialStore, session: SessionManager, api: KvittApi, gate: OnlineGate, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, api, gate, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
}
/** The `:core:model` interfaces bound to `:data`. Not installed automatically: `:app` includes it in its release graph. */
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -14,12 +15,15 @@ import se.hagstedt.kvitt.data.api.CreateInviteRequest
import se.hagstedt.kvitt.data.api.CreateTemplateRequest
import se.hagstedt.kvitt.data.api.DeviceDto
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.ExpensePushEntity
import se.hagstedt.kvitt.data.api.InviteDto
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.Patch
import se.hagstedt.kvitt.data.api.PatchCategoryRequest
import se.hagstedt.kvitt.data.api.PatchMeRequest
import se.hagstedt.kvitt.data.api.PatchTemplateRequest
import se.hagstedt.kvitt.data.api.SettlementPushEntity
import se.hagstedt.kvitt.data.api.TemplateSplitDto
import se.hagstedt.kvitt.data.auth.CredentialStore
import se.hagstedt.kvitt.data.connectivity.OnlineGate
@@ -43,6 +47,7 @@ import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.EntityType
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
@@ -53,7 +58,10 @@ 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.RejectedVersion
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.SyncRepository
@@ -185,7 +193,7 @@ class RecurringRepositoryImpl(
}
/** E-28 body from the model; splits per §9.1: none for default, two bp for ratio and full, two öre for exact. */
private fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
internal fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
val accountId = draft.accountId ?: return null
val categoryId = draft.categoryId ?: return null
val amount = draft.amountOre ?: return null
@@ -313,6 +321,7 @@ class SyncRepositoryImpl(
* A rejection for S-16. `summary` is the rejected entity's own text (expense description; settlement note, or its
* amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6). `actor` is the
* `updated_by_user_id` of the server's `current` row stored with the rejection (§14.4.3), resolved to a household user.
* `lost` is the pushed entity itself (`rejected_payload`), for the read-only view of S-16 (D-109).
*/
internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
val type = EntityType.entries.firstOrNull { it.name.lowercase() == entityType } ?: return null
@@ -323,7 +332,42 @@ internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
EntityType.EXPENSE -> payload?.get("description")?.jsonPrimitive?.contentOrNull
EntityType.SETTLEMENT -> payload?.get("note")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: payload?.get("amount_ore")?.jsonPrimitive?.contentOrNull
}
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor)
val lost = payload?.let { s.rejectedVersion(type, it) }
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor, lost)
}
/** The pushed entity as the row it would have been; null when it does not decode or its category or users are unknown. */
private fun Snapshot.rejectedVersion(type: EntityType, payload: JsonObject): RejectedVersion? = try {
when (type) {
EntityType.EXPENSE -> {
val e = KvittJson.decodeFromJsonElement(ExpensePushEntity.serializer(), payload)
val viewer = meId
val category = category(e.categoryId)
if (viewer == null || category == null) {
null
} else {
val splits = e.splits.map { it.userId to it.shareOre }
val row = LedgerRow.ExpenseRow(ExpenseId(e.id), e.expenseDate, e.description, category, e.amountOre, effectOf(e.payerUserId, splits, viewer), pending = false)
val shares = e.splits.sortedBy { it.userId }.map { Share(UserId(it.userId), it.shareOre, it.shareBp) }
RejectedVersion(row, account(e.accountId)?.name, user(e.payerUserId), splitModeOf(e.splitMode), shares, e.notes, e.deletedAt != null)
}
}
EntityType.SETTLEMENT -> {
val st = KvittJson.decodeFromJsonElement(SettlementPushEntity.serializer(), payload)
val from = user(st.fromUserId)
val to = user(st.toUserId)
if (from == null || to == null) {
null
} else {
val row = LedgerRow.SettlementRow(SettlementId(st.id), st.settlementDate, from, to, st.amountOre, st.allocations.size, pending = false)
RejectedVersion(row, null, null, null, emptyList(), st.note, st.deletedAt != null)
}
}
}
} catch (e: kotlinx.serialization.SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
private fun jsonObjectOrNull(text: String): JsonObject? = try {
@@ -332,15 +376,27 @@ private fun jsonObjectOrNull(text: String): JsonObject? = try {
null
}
/** S-22 (FR-16 versions, the paired server and the server's version header). */
/**
* S-22 (FR-16 versions, the paired server). Each collection (S-22 opening) asks E-1 `/health` once when online, for
* `server_version` and `schema_version` (§13.3, D-110); until it answers, offline or when it fails, the server version
* is the `X-Kvitt-Server-Version` header of the last response and the schema version is null.
*/
class AboutRepositoryImpl(
private val credentials: CredentialStore,
private val serverVersion: Flow<String?>,
private val api: KvittApi,
private val gate: OnlineGate,
private val versionName: String,
private val versionCode: Int,
private val defaultServerUrl: String,
) : AboutRepository {
override fun about(): Flow<AboutState> = combine(credentials.record, serverVersion) { record, server ->
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, server)
}.distinctUntilChanged()
override fun about(): Flow<AboutState> {
val health = flow {
emit(null)
emit((gate.run({ api.health() }) { it } as? OnlineResult.Ok)?.value)
}
return combine(credentials.record, serverVersion, health) { record, header, h ->
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, h?.serverVersion ?: header, h?.schemaVersion)
}.distinctUntilChanged()
}
}
@@ -44,10 +44,11 @@ class SettlementRepositoryImpl(
) : SettlementRepository {
private val timelines = Timelines(db, local)
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
/** Null without a paired session or before the partner has joined: S-9 is not offered then (AC-5.1, D-111). */
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? {
val s = local.snapshot.first()
val me = checkNotNull(s.me) { "Settle up needs a paired session" }
val partner = checkNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
val me = s.me ?: return null
val partner = s.partner ?: return null
val existing = editing?.let { s.settlement(it.value) }?.takeIf { !it.deleted }
if (existing != null) {
return s.settleState(
@@ -61,11 +62,12 @@ class SettlementRepositoryImpl(
return s.settleState(SettleRequest(null, payer, myNet.absoluteValue.takeIf { it > 0 }, fixedAccount, null, clock.today(), ""))
}
override suspend fun recompute(request: SettleRequest): SettleState = local.snapshot.first().settleState(request)
override suspend fun recompute(request: SettleRequest): SettleState? = local.snapshot.first().settleState(request)
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
val s = local.snapshot.first()
val state = s.settleState(request)
// D-111: an unknown payer or no partner yet cannot be saved; the screen is not offered then (AC-5.1).
val state = s.settleState(request) ?: return SaveResult.Invalid(listOf(FieldError("payer", ExpenseRepositoryImpl.VALIDATION_FAILED)))
val errors = mutableListOf<FieldError>()
val amount = request.amountOre
if (amount == null) {
@@ -145,11 +147,12 @@ private fun SettleState.allocations(): List<Allocation> = lines.map { Allocation
/**
* §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).
* per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6). Null when
* the payer is not a household user or there is no recipient yet (D-111).
*/
internal fun Snapshot.settleState(request: SettleRequest): SettleState {
val payer = requireNotNull(user(request.payer.value)) { "unknown payer" }
val recipient = requireNotNull(household.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" }
internal fun Snapshot.settleState(request: SettleRequest): SettleState? {
val payer = user(request.payer.value) ?: return null
val recipient = household.firstOrNull { it.id != request.payer } ?: return null
val balances = balances(excludingSettlement = request.editing?.value)
val positions = accounts.map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id.value, request.payer)) }
val amount = request.amountOre
@@ -121,15 +121,7 @@ data class Snapshot(
)
/** §9.2 from the viewer's side. */
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect {
val payer = e.row.payerUserId
val nonPayerShare = e.splits.firstOrNull { it.userId != payer }?.shareOre ?: 0L
return when {
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(e.splits.firstOrNull { it.userId == viewer.value }?.shareOre ?: 0L)
}
}
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect = effectOf(e.row.payerUserId, e.splits.map { it.userId to it.shareOre }, viewer)
/** A ledger row, or null while its category has not arrived (no foreign keys, §14.2). */
fun expenseRow(e: ExpenseRecord): LedgerRow.ExpenseRow? {
@@ -147,6 +139,16 @@ data class Snapshot(
}
}
/** §9.2 for a payer and the (user id, share in öre) pairs of an expense, from [viewer]'s side. */
fun effectOf(payer: String, splits: List<Pair<String, Long>>, viewer: UserId): ExpenseEffect {
val nonPayerShare = splits.firstOrNull { it.first != payer }?.second ?: 0L
return when {
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
else -> ExpenseEffect.Borrowed(splits.firstOrNull { it.first == viewer.value }?.second ?: 0L)
}
}
/** AC-8.10: date descending, then `created_at` descending. */
val ledgerOrder: Comparator<Pair<String, String>> = compareByDescending<Pair<String, String>> { it.first }.thenByDescending { it.second }
@@ -34,6 +34,7 @@ import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.SettlementDto
import se.hagstedt.kvitt.data.api.ShareBpDto
import se.hagstedt.kvitt.data.api.SplitDto
import se.hagstedt.kvitt.data.api.TemplateSplitDto
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
@@ -44,15 +45,18 @@ import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEffect
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldError
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.SyncRejectionReason
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
@@ -206,7 +210,7 @@ class RepositoriesTest {
fun settleStart_theUserWhoOwesPays_amountIsTheBalance() = runSync {
h.addExpense("ICA", amountOre = 1001)
h.local.snapshot.first { it.liveExpenses.isNotEmpty() }
val state = h.settlementRepo().start(null, null)
val state = requireNotNull(h.settlementRepo().start(null, null))
assertEquals(PARTNER, state.payer.id.value)
assertEquals(500L, state.amountOre)
assertEquals(listOf(500L), state.lines.map { it.amountOre })
@@ -216,6 +220,25 @@ class RepositoriesTest {
assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre)
}
@Test
fun settleStart_withoutAPartner_isNull_andSaveIsInvalid_neverAThrow() = runSync {
val alone = Harness()
try {
alone.server.addUser(ME, "admin", "Jacob")
alone.server.addCategory(OTHER, "other", null)
alone.pair()
alone.engine.sync()
alone.local.snapshot.first { it.me != null }
assertNull("D-111: no partner yet (AC-5.1)", alone.settlementRepo().start(null, null))
val request = se.hagstedt.kvitt.model.SettleRequest(null, UserId(ME), 100, null, null, "2026-10-17", "")
assertNull(alone.settlementRepo().recompute(request))
val saved = alone.settlementRepo().save(request)
assertEquals(SaveResult.Invalid(listOf(FieldError("payer", "validation_failed"))), saved)
} finally {
alone.close()
}
}
@Test
fun settleState_listsTheAccountsTheSettlementLeavesUntouched() = runSync {
h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z")
@@ -229,14 +252,14 @@ class RepositoriesTest {
)
h.engine.sync()
h.local.snapshot.first { it.liveExpenses.size == 2 }
val state = h.settlementRepo().start(null, null)
val state = requireNotNull(h.settlementRepo().start(null, null))
assertEquals(PARTNER, state.payer.id.value)
assertEquals(listOf(ACCOUNT), state.lines.map { it.account.id.value })
assertEquals(listOf(OLDER to 500L), state.untouched.map { it.account.id.value to it.payerNetOre })
// Rows naming Hemma touch it; a per-account settle-up lists nothing.
val both = h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, ""))
val both = requireNotNull(h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, "")))
assertTrue(both.untouched.isEmpty())
assertTrue(h.settlementRepo().start(null, AccountId(ACCOUNT)).untouched.isEmpty())
assertTrue(requireNotNull(h.settlementRepo().start(null, AccountId(ACCOUNT))).untouched.isEmpty())
}
@Test
@@ -305,6 +328,148 @@ class RepositoriesTest {
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertEquals(SyncRejectionReason.STALE, rejection.reason)
assertEquals(PARTNER, rejection.actor?.id?.value)
// D-109: the phone's lost version, from rejected_payload, not the server's winning row.
val lost = requireNotNull(rejection.lost)
val row = lost.row as LedgerRow.ExpenseRow
assertEquals("ICA", row.description)
assertEquals(41200L, row.amountOre)
assertEquals(GROCERIES, row.category.id.value)
assertEquals(ExpenseEffect.Lent(20600), row.effect)
assertFalse(row.pending)
assertEquals("Vardag", lost.accountName)
assertEquals(ME, lost.payer?.id?.value)
assertEquals(SplitMode.DEFAULT, lost.mode)
assertEquals(listOf(5000, 5000), lost.shares.map { it.shareBp })
assertFalse(lost.deleted)
}
@Test
fun syncStatus_aRefusedDeletionShowsTheDeletedLostVersion() = runSync {
val id = h.addExpense("ICA")
h.expenseRepo().delete(ExpenseId(id))
h.server.pushOverride = { request ->
val m = request.mutations.single()
se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "validation_failed", null)))
}
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
repo.syncNow()
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertTrue(rejection.lost?.deleted == true)
assertEquals("ICA", (rejection.lost?.row as LedgerRow.ExpenseRow).description)
}
@Test
fun syncStatus_anUnreadablePayloadHasNoLostVersion() = runSync {
h.db.rejections().insertOrIgnore(
se.hagstedt.kvitt.data.db.SyncRejectionEntity("m-x", "expense", "e-x", "stale", "", "not json", null, "2026-10-17T10:00:00.000Z", false),
)
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
assertNull(rejection.lost)
assertEquals("", rejection.summary)
}
@Test
fun about_readsServerAndSchemaVersionFromHealth_whenOnline() = runSync {
h.server.restHandler = { _, path ->
if (path == "/health") HttpStatusCode.OK to """{"status":"ok","server_version":"1.2.0","schema_version":3,"db_ok":true}""" else null
}
val repo = AboutRepositoryImpl(h.credentials, kotlinx.coroutines.flow.MutableStateFlow("1.1.0"), h.api, h.gate, "1.1", 11, "https://default.test")
val state = repo.about().first { it.schemaVersion != null }
assertEquals("1.2.0", state.serverVersion)
assertEquals(3, state.schemaVersion)
assertEquals(Harness.BASE_URL, state.serverUrl)
val health = h.requests.single { it.url.encodedPath == "/health" }
assertNull("E-1 is unauthenticated", health.headers["Authorization"])
}
@Test
fun about_offlineOrFailingHealth_fallsBackToTheHeaderVersion() = runSync {
val header = kotlinx.coroutines.flow.MutableStateFlow<String?>("1.1.0")
h.monitor.state.value = false
val repo = AboutRepositoryImpl(h.credentials, header, h.api, h.gate, "1.1", 11, "https://default.test")
val offline = repo.about().first()
assertEquals("1.1.0", offline.serverVersion)
assertNull(offline.schemaVersion)
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
assertTrue("no request offline", h.requests.none { it.url.encodedPath == "/health" })
h.monitor.state.value = true
h.server.restHandler = { _, path -> if (path == "/health") HttpStatusCode.ServiceUnavailable to """{"code":"internal","message":"x","field":null}""" else null }
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
assertEquals("1.1.0", repo.about().first().serverVersion)
}
@Test
fun categoryReorder_sendsOnePatchPerChangedSortOrder() = runSync {
val patched = mutableListOf<Pair<String, Int?>>()
h.server.restHandler = { request, path ->
if (request.method == HttpMethod.Patch && path.startsWith("/categories/")) {
val id = path.removePrefix("/categories/")
val text = when (val content = request.body) {
is io.ktor.http.content.TextContent -> content.text
is io.ktor.http.content.OutgoingContent.ByteArrayContent -> String(content.bytes())
else -> throw AssertionError("unexpected body ${content::class.java.name}")
}
val body = KvittJson.parseToJsonElement(text)
val order = (body as kotlinx.serialization.json.JsonObject)["sort_order"]?.toString()?.toInt()
patched += id to order
val updated = h.server.categories.getValue(id).copy(sortOrder = order ?: 0, serverVersion = h.server.next())
h.server.categories[id] = updated
HttpStatusCode.OK to KvittJson.encodeToString(se.hagstedt.kvitt.data.api.CategoryDto.serializer(), updated)
} else {
null
}
}
val repo = CategoryRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock)
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(GROCERIES), CategoryId(OTHER))))
assertTrue("unchanged order: no request", patched.isEmpty())
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(OTHER), CategoryId(GROCERIES))))
assertEquals(listOf(OTHER to 10, GROCERIES to 20), patched)
assertEquals(listOf(OTHER, GROCERIES), repo.all().first { it.first().id.value == OTHER }.map { it.id.value })
}
@Test
fun recurringToWire_splitsPerMode() {
val me = UserId(ME)
val partner = UserId(PARTNER)
fun template(mode: SplitMode, ratio: Map<UserId, Int>? = null, exact: Map<UserId, Long>? = null, fullOn: UserId? = null) = NewRecurringTemplate(
se.hagstedt.kvitt.model.ExpenseDraft(null, AccountId(ACCOUNT), CategoryId(GROCERIES), 10_000, " Hyra ", "", "2026-11-01", me, mode, ratio, exact, fullOn),
dayOfMonth = 1, startPeriod = "2026-11", endPeriod = null, paused = false,
)
val household = listOf(me, partner)
val default = requireNotNull(template(SplitMode.DEFAULT).toWire(household))
assertTrue("default: no splits", default.splits.isEmpty())
assertEquals("Hyra", default.description)
assertEquals("default", default.splitMode)
val ratio = requireNotNull(template(SplitMode.RATIO, ratio = mapOf(partner to 4_000, me to 6_000)).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, 6_000, null), TemplateSplitDto(PARTNER, 4_000, null)), ratio.splits)
val full = requireNotNull(template(SplitMode.FULL, fullOn = partner).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, 0, null), TemplateSplitDto(PARTNER, 10_000, null)), full.splits)
val exact = requireNotNull(template(SplitMode.EXACT, exact = mapOf(me to 2_500L, partner to 7_500L)).toWire(household))
assertEquals(listOf(TemplateSplitDto(ME, null, 2_500), TemplateSplitDto(PARTNER, null, 7_500)), exact.splits)
assertNull("ratio without bp", template(SplitMode.RATIO).toWire(household))
assertNull("full without a bearer", template(SplitMode.FULL).toWire(household))
assertNull("full before the partner joined", template(SplitMode.FULL, fullOn = me).toWire(listOf(me)))
}
@Test
fun effectLine_theFourCasesOfSection9_2() = runSync {
// §9.2 from the viewer (me): I paid and Anna has a share; Anna paid and I have a share; I paid it all for
// myself; Anna paid it all for herself.
fun record(payer: String, mine: Long, hers: Long) = ExpenseRecord(
se.hagstedt.kvitt.data.db.ExpenseEntity(
"e", ACCOUNT, GROCERIES, mine + hers, "x", "", "2026-10-10", payer, "exact", null, null, ME, ME,
FakeServer.T0, FakeServer.T0, null, 1, false, null, 0,
),
listOf(se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", ME, mine, null), se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", PARTNER, hers, null)),
)
val s = h.local.snapshot.first { it.me != null }
val me = UserId(ME)
assertEquals(ExpenseEffect.Lent(700), s.effectFor(record(ME, 300, 700), me))
assertEquals(ExpenseEffect.Borrowed(300), s.effectFor(record(PARTNER, 300, 700), me))
assertEquals(ExpenseEffect.PaidForYourself, s.effectFor(record(ME, 1000, 0), me))
assertEquals(ExpenseEffect.NotInvolved, s.effectFor(record(PARTNER, 0, 1000), me))
}
@Test