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 }