M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

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 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -9,25 +9,28 @@ 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.InviteKind
import se.hagstedt.kvitt.model.LedgerMonth
import se.hagstedt.kvitt.model.LedgerRow
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Pairing
import se.hagstedt.kvitt.model.Role
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> =
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> =
store.online { s ->
store.edit { it.copy(session = SessionState.Paired(s.me, s.partner, s.deviceId)) }
OnlineResult.Ok(s.me)
val kind = s.pairingInviteKind ?: if (s.me.role == Role.MEMBER) InviteKind.PARTNER else InviteKind.ADMIN_SETUP
OnlineResult.Ok(Pairing(s.me, kind))
}
override suspend fun acknowledgeRevoked() = store.edit { it.copy(session = SessionState.Unpaired) }
@@ -39,6 +42,7 @@ internal fun FakeScenario.syncSummary(): SyncSummary = SyncSummary(
rejectedCount = rejections.size,
clockSkew = clockSkew,
running = syncRunning,
lastAttemptFailed = lastAttemptFailed && !syncRunning,
)
internal fun UpdateState.info(): UpdateInfo? = when (this) {
@@ -9,6 +9,7 @@ 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.InviteKind
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Rejection
import se.hagstedt.kvitt.model.SessionState
@@ -63,6 +64,8 @@ data class FakeSettlement(
* @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.
* @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs).
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
*/
data class FakeScenario(
val me: User,
@@ -87,6 +90,8 @@ data class FakeScenario(
val now: String,
val defaultAccountId: AccountId?,
val lastCategoryFor: Map<AccountId, CategoryId>,
val lastAttemptFailed: Boolean = false,
val pairingInviteKind: InviteKind? = null,
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
@@ -63,7 +63,7 @@ object FakeScenarios {
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),
Device(ownerDevice, owner, "Galaxy S26", "2026-10-17T06:00:00.000Z", "2026-10-17T06:00:00.000Z", 1, "1.0", current = true, revoked = false),
),
invites = emptyList(),
rejections = emptyList(),
@@ -26,12 +26,14 @@ 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.NewRecurringTemplate
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.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
@@ -124,10 +126,11 @@ class FakeCategoryRepository(private val store: FakeStore) : CategoryRepository
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))
override suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId> = store.online { s ->
val id = TemplateId(store.newId())
val template = RecurringTemplate(id, t.draft, t.dayOfMonth, t.startPeriod, t.endPeriod, t.paused, nextOccurrence = null)
s.validateTemplate(template) ?: run {
val created = template.copy(nextOccurrence = s.nextOccurrence(template))
store.edit { it.copy(templates = it.templates + created) }
OnlineResult.Ok(id)
}
@@ -272,11 +275,17 @@ class FakeSyncRepository(private val store: FakeStore) : SyncRepository {
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 {
/**
* The update state comes from the scenario; `check` and `install` leave it unchanged (debug builds never update,
* AC-16.5). `check` reports the scenario's state: `Available` as found, anything else as up to date.
*/
class FakeUpdateRepository(private val store: FakeStore) : UpdateRepository {
override val state: Flow<UpdateState> = store.derive { it.update }
override suspend fun check() = Unit
override suspend fun check(): UpdateCheck = when (val update = store.current.update) {
is UpdateState.Available -> UpdateCheck.Available(update.info)
else -> UpdateCheck.UpToDate
}
override suspend fun install() = Unit
}
@@ -13,6 +13,7 @@ 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.UntouchedAccount
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.money.AccountPosition
import se.hagstedt.kvitt.model.money.Allocation
@@ -129,6 +130,14 @@ private fun FakeScenario.settleState(request: SettleRequest): SettleState {
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)
val untouched = if (fixed != null) {
emptyList()
} else {
accountsInOrder().mapNotNull { account ->
val net = balances.net(account.id, request.payer)
UntouchedAccount(account, net).takeIf { net > 0 && lines.none { it.account.id == account.id } }
}
}
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note, untouched)
}
@@ -120,7 +120,7 @@ object HomeScenarios {
)
/** A `stale` rejection of Anna's newer Babette expense (S-16 loaded). */
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z")
val staleRejection = Rejection("m-1", EntityType.EXPENSE, id("e", 12), SyncRejectionReason.STALE, "Babette", "2026-10-17T06:31:00.000Z", FakeScenarios.partner)
/** S-16 loaded: two changes waiting and one `stale` rejection. */
val syncLoaded: FakeScenario = pending.copy(rejections = listOf(staleRejection))
@@ -180,10 +180,10 @@ object HomeScenarios {
val allRejections: FakeScenario = pending.copy(
rejections = listOf(
staleRejection,
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z"),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z"),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z"),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z"),
Rejection("m-2", EntityType.EXPENSE, id("e", 13), SyncRejectionReason.DELETED, "Pressbyrån Odenplan", "2026-10-17T07:10:00.000Z", null),
Rejection("m-3", EntityType.SETTLEMENT, id("5", 9), SyncRejectionReason.VALIDATION_FAILED, "Betalning till Anna", "2026-10-17T07:42:00.000Z", null),
Rejection("m-4", EntityType.EXPENSE, id("e", 14), SyncRejectionReason.REFERENCE_NOT_FOUND, "Glass i Visby hamn", "2026-10-16T18:05:00.000Z", null),
Rejection("m-5", EntityType.EXPENSE, id("e", 15), SyncRejectionReason.CLOCK_SKEW, "Biobiljetter Rigoletto", "2026-10-16T17:20:00.000Z", null),
),
)
@@ -11,6 +11,7 @@ import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Pairing
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
@@ -46,7 +47,7 @@ class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: C
override val session: Flow<SessionState> = delegate.session
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> {
pairCalls += PairCall(serverUrl, code, displayName, deviceName)
return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) }
}
@@ -15,9 +15,11 @@ import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
@@ -65,7 +67,7 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) {
}
val recurring: RecurringRepository = object : RecurringRepository by fakes.recurring {
override suspend fun create(t: RecurringTemplate) = script.run { fakes.recurring.create(t) }
override suspend fun create(t: NewRecurringTemplate) = script.run { fakes.recurring.create(t) }
override suspend fun update(t: RecurringTemplate) = script.run { fakes.recurring.update(t) }
override suspend fun delete(id: TemplateId) = script.run { fakes.recurring.delete(id) }
}
@@ -89,12 +91,17 @@ class ScriptedSettingsRepositories(scenario: FakeScenario) {
/** `check` of the fake leaves the state unchanged; [afterCheck] is the state a check finds (S-22 result states). */
var afterCheck: UpdateState? = null
/** A check that cannot reach the server: `Failed`, the state unchanged. */
var checkFails: Boolean = false
val update: UpdateRepository = object : UpdateRepository {
override val state = fakes.update.state
override suspend fun check() {
override suspend fun check(): UpdateCheck {
if (script.hang) awaitCancellation()
if (checkFails) return UpdateCheck.Failed
afterCheck?.let { found -> fakes.store.edit { it.copy(update = found) } }
return fakes.update.check()
}
override suspend fun install() = fakes.update.install()
@@ -97,9 +97,9 @@ object SettingsScenarios {
)
private val devices = listOf(
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, current = true, revoked = false),
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, current = false, revoked = false),
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, current = false, revoked = true),
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, "1.1", current = true, revoked = false),
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, "1.1", current = false, revoked = false),
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, "1.0", current = false, revoked = true),
)
private val pendingDeviceInvite = Invite(InviteId("01920000-0000-7000-8000-0000000000aa"), InviteKind.DEVICE, anna.id, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")
@@ -13,10 +13,9 @@ import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.NewRecurringTemplate
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
/** UX-9 in the fakes: `frequentCategoryIds` and `deletable` follow the same rule as `:data`. */
class FakeEditorCategoriesTest {
@@ -72,10 +71,9 @@ class FakeEditorCategoriesTest {
assertFalse(fakes.categories.deletable(pant).first())
fakes.expenses.delete(e)
assertTrue(fakes.categories.deletable(pant).first())
val template = RecurringTemplate(
TemplateId("t"),
val template = NewRecurringTemplate(
ExpenseDraft(null, vardag, pant, 10_000, "Pant", "", FakeScenarios.empty.today, FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null),
1, "2026-10", null, paused = false, nextOccurrence = null,
1, "2026-10", null, paused = false,
)
val created = (fakes.recurring.create(template) as OnlineResult.Ok).value
assertFalse(fakes.categories.deletable(pant).first())