M7d: feature settle, S-9 settle up and S-10 settlement detail

SettleScreen (global, per account, editing) and SettlementDetailScreen with
Hilt ViewModels over the :core:model interfaces; proposals, remaining,
warnings and enablement come from SettlementRepository. Fake scenarios in
fakes/settle/, 19 states x sv/en x light/dark goldens (+ 200 % for long),
unit tests for amount input and both state holders.

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 10:44:43 +02:00
parent ea7d494ffa
commit 7380e50ae3
16 changed files with 2002 additions and 14 deletions
@@ -0,0 +1,191 @@
package se.hagstedt.kvitt.fakes.settle
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.FakeSettlement
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.ExpenseId
import se.hagstedt.kvitt.model.FieldChange
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TimelineLine
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/**
* Scenarios of `:feature:settle` (S-9 settle up, S-10 settlement detail; PLAN-M7d §5). Every balance comes from the
* scenario's expenses and settlements through the fakes' §9.3 rules, so the proposals are the real §9.4 ones.
*
* The base household (the design-spec frames): Jacob (the viewer) owes 3 084,50 kr in Huset and 1 350,00 kr in
* Japan 2027; Anna owes 1 184,50 kr in Vardag; the archived account Stugan is settled. Globally Jacob owes 3 250,00 kr.
*/
object SettleScenarios {
private val jacob: User = FakeScenarios.owner
private val anna: User = FakeScenarios.partner
val vardag = account(0xb1, "Vardag", "2025-01-10T08:00:00.000Z")
val huset = account(0xb2, "Huset", "2025-02-01T08:00:00.000Z")
val japan = account(0xb3, "Japan 2027", "2026-03-15T08:00:00.000Z")
val stugan = account(0xb4, "Stugan", "2024-06-01T08:00:00.000Z", archived = true)
/** Anna → Jacob 800,00 kr on 6 October, all to Vardag, note "Swish", created by Anna (frame S-10-settlement). */
val swish = SettlementId(id(0xc1))
/** Jacob → Anna 2 500,00 kr over Huset and Japan 2027, no note, created, changed, and a pending local change. */
val split = SettlementId(id(0xc2))
/** Anna → Jacob 450,00 kr, deleted by Anna on 17 October (frame S-10-settlement-deleted). */
val deleted = SettlementId(id(0xc3))
/** Jacob → Anna 1 234 567,89 kr over six long-named accounts (long content). */
val long = SettlementId(id(0xc4))
/** The settlement id that no scenario holds (the missing state). */
val missing = SettlementId(id(0xcf))
/** S-9 global, loaded: the proposal Huset 3 084,50 + Japan 2027 165,50 and the info line about Vardag. */
val proposal: FakeScenario = FakeScenarios.empty.copy(
accounts = listOf(stugan, vardag, huset, japan),
expenses = listOf(
expense(0xe1, huset, anna, 616_900, "2026-10-02"),
expense(0xe2, japan, anna, 270_000, "2026-10-05"),
expense(0xe3, vardag, jacob, 236_900, "2026-10-09"),
),
lastSuccessAt = "2026-10-17T11:59:00.000Z",
today = "2026-10-17",
now = "2026-10-17T12:02:00.000Z",
)
/** S-9 global with a zero balance: the amount is empty and no rows are proposed. */
val settledUp: FakeScenario = proposal.copy(
expenses = proposal.expenses + listOf(expense(0xe4, vardag, jacob, 650_000, "2026-10-12")),
)
/** S-9 with the partner owing globally: the direction defaults to "Anna betalar dig". */
val partnerOwes: FakeScenario = proposal.copy(
expenses = listOf(
expense(0xe1, huset, jacob, 616_900, "2026-10-02"),
expense(0xe2, japan, jacob, 270_000, "2026-10-05"),
expense(0xe3, vardag, anna, 236_900, "2026-10-09"),
),
)
/** S-9 offline with pending changes: settling up works unchanged (AC-11.6). */
val offline: FakeScenario = proposal.copy(
online = false,
expenses = proposal.expenses.map { if (it.id.value == id(0xe3)) it.copy(pending = true) else it },
lastSuccessAt = "2026-10-17T09:30:00.000Z",
)
/** S-10 and S-9 editing: the four settlements above on top of [proposal]. */
val detail: FakeScenario = proposal.copy(
settlements = listOf(
FakeSettlement(
id = swish, from = anna.id, to = jacob.id, amountOre = 80_000, date = "2026-10-06", note = "Swish",
allocations = mapOf(vardag.id to 80_000L), createdAt = "2026-10-06T19:15:00.000Z",
timeline = listOf(TimelineLine(anna, AuditAction.CREATE, AuditSource.APP, "2026-10-06T19:15:00.000Z", emptyList(), pendingLocal = false)),
),
FakeSettlement(
id = split, from = jacob.id, to = anna.id, amountOre = 250_000, date = "2026-10-14", note = "",
allocations = linkedMapOf(huset.id to 200_000L, japan.id to 50_000L), createdAt = "2026-10-14T07:40:00.000Z",
pending = true,
timeline = listOf(
TimelineLine(jacob, AuditAction.CREATE, AuditSource.APP, "2026-10-14T07:40:00.000Z", emptyList(), pendingLocal = false),
TimelineLine(
anna, AuditAction.UPDATE, AuditSource.APP, "2026-10-15T16:05:00.000Z",
listOf(FieldChange("amount_ore", "200000", "250000"), FieldChange("settlement_date", "2026-10-13", "2026-10-14")),
pendingLocal = false,
),
),
),
FakeSettlement(
id = deleted, from = anna.id, to = jacob.id, amountOre = 45_000, date = "2026-10-11", note = "Swish för hyran",
allocations = mapOf(vardag.id to 45_000L), createdAt = "2026-10-11T10:20:00.000Z", deleted = true,
timeline = listOf(
TimelineLine(anna, AuditAction.CREATE, AuditSource.APP, "2026-10-11T10:20:00.000Z", emptyList(), pendingLocal = false),
TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false),
),
),
),
)
/** S-10 offline: the detail of [split] with the phone offline. */
val detailOffline: FakeScenario = detail.copy(online = false, lastSuccessAt = "2026-10-17T09:30:00.000Z")
private val longAccounts: List<Account> = listOf(
account(0xd1, "Sommarhuset i Rättvik med bryggbygget", "2025-01-02T08:00:00.000Z"),
account(0xd2, "Gemensamma hushållsutgifter och matinköp", "2025-01-03T08:00:00.000Z"),
account(0xd3, "Bilen, försäkringarna och parkeringstillstånden", "2025-01-04T08:00:00.000Z"),
account(0xd4, "Renoveringen av badrummet på övervåningen", "2025-01-05T08:00:00.000Z"),
account(0xd5, "Bröllopsresan till Nya Zeeland och Australien", "2025-01-06T08:00:00.000Z"),
account(0xd6, "Barnens fritidsaktiviteter och utrustning", "2025-01-07T08:00:00.000Z"),
)
/** Long content (S-9): six long-named accounts where Jacob owes 7-digit amounts (1 235 677,89 kr globally). */
val longContent: FakeScenario = FakeScenarios.empty.copy(
accounts = longAccounts,
expenses = listOf(
expense(0xf1, longAccounts[0], anna, 104_691_356, "2026-10-01"),
expense(0xf2, longAccounts[1], anna, 82_469_134, "2026-10-02"),
expense(0xf3, longAccounts[2], anna, 37_530_864, "2026-10-03"),
expense(0xf4, longAccounts[3], anna, 24_691_356, "2026-10-04"),
expense(0xf5, longAccounts[4], anna, 6_913_578, "2026-10-05"),
expense(0xf6, longAccounts[5], jacob, 9_160_710, "2026-10-06"),
),
lastSuccessAt = "2026-10-17T11:59:00.000Z",
today = "2026-10-17",
now = "2026-10-17T12:02:00.000Z",
)
/** Long content (S-10): Jacob → Anna 1 234 567,89 kr over the six long-named accounts, with a long note. */
val longDetail: FakeScenario = longContent.copy(
settlements = listOf(
FakeSettlement(
id = long, from = jacob.id, to = anna.id, amountOre = 123_456_789, date = "2026-09-30",
note = "Överföring från sparkontot inför flytten, enligt överenskommelsen vi gjorde på midsommar",
allocations = linkedMapOf(
longAccounts[0].id to 50_000_000L,
longAccounts[1].id to 40_000_000L,
longAccounts[2].id to 15_000_000L,
longAccounts[3].id to 10_000_000L,
longAccounts[4].id to 5_000_000L,
longAccounts[5].id to 3_456_789L,
),
createdAt = "2026-09-30T18:00:00.000Z",
timeline = listOf(TimelineLine(jacob, AuditAction.CREATE, AuditSource.APP, "2026-09-30T18:00:00.000Z", emptyList(), pendingLocal = false)),
),
),
)
/** The long scenario's accounts in created order. */
val longAccountIds: List<AccountId> = longAccounts.map { it.id }
private fun id(suffix: Int): String = "01920000-0000-7000-8000-" + suffix.toString(16).padStart(12, '0')
private fun account(suffix: Int, name: String, createdAt: String, archived: Boolean = false): Account =
Account(AccountId(id(suffix)), name, mapOf(jacob.id to 5000, anna.id to 5000), createdAt, archived)
/** An expense split 50/50 that [payer] paid: the other person owes half of it in [account]. */
private fun expense(suffix: Int, account: Account, payer: User, amountOre: Long, date: String): FakeExpense {
val half = amountOre / 2
val shares = listOf(jacob.id, anna.id).map { user: UserId -> Share(user, if (user == payer.id) amountOre - half else half, 5000) }
return FakeExpense(
id = ExpenseId(id(suffix)),
accountId = account.id,
categoryId = FakeScenarios.defaultCategories.first().id,
amountOre = amountOre,
description = "Inköp",
notes = "",
date = date,
payer = payer.id,
mode = SplitMode.DEFAULT,
shares = shares,
createdAt = "${date}T10:00:00.000Z",
)
}
}