M7c: feature expense, S-7 editor and S-8 detail with timeline

Expense editor (S-7) and expense detail (S-8) in :feature:expense against the
:core:model interfaces and :core:design components: defaults (AC-8.6), keypad
input (§9.6), the four split modes with live preview (AC-8.5, AC-8.7, AC-8.8),
category suggestion with the picked-by-hand flag (CS-2, CS-3, CS-7), quick row
from frequentCategoryIds (D-059, D-060), inline errors, delete and discard
confirmations; detail with hero, facts, timeline, recurring, deleted and missing
states. Fake scenarios in fakes/expense, view-model tests and 127 goldens.

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:54:16 +02:00
parent 0e1d99df93
commit 0b140aa3ad
15 changed files with 2520 additions and 14 deletions
@@ -0,0 +1,266 @@
package se.hagstedt.kvitt.fakes.expense
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
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.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.FieldChange
import se.hagstedt.kvitt.model.RecurringTemplate
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.UserId
/**
* Scenarios of `:feature:expense` (S-7 expense editor, S-8 expense detail; PLAN-M7c §5), as in the design spec's
* frames: Jacob (the viewer) and Anna, the accounts Vardag (50/50), Huset (60/40), Japan 2027 and the archived Gotland
* 2026, and the expenses the frames show. The phone's clock is 17 October 2026 14:02 in Stockholm.
*/
object ExpenseScenarios {
private val jacob = FakeScenarios.owner
private val anna = FakeScenarios.partner
private fun category(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.single { it.defaultKey == key }
val groceries = category(DefaultCategoryKey.GROCERIES)
val restaurants = category(DefaultCategoryKey.RESTAURANTS)
val housing = category(DefaultCategoryKey.HOUSING)
val transport = category(DefaultCategoryKey.TRANSPORT)
val entertainment = category(DefaultCategoryKey.ENTERTAINMENT)
val shopping = category(DefaultCategoryKey.SHOPPING)
val other = category(DefaultCategoryKey.OTHER)
private fun shares(jacobBp: Int) = mapOf(jacob.id to jacobBp, anna.id to 10_000 - jacobBp)
val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", shares(5000), "2026-01-04T09:00:00.000Z", archived = false)
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", shares(6000), "2026-01-05T09:00:00.000Z", archived = false)
val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", shares(5000), "2026-03-01T09:00:00.000Z", archived = false)
val gotland = Account(AccountId("01920000-0000-7000-8000-0000000000b4"), "Gotland 2026", shares(5000), "2026-05-20T09:00:00.000Z", archived = true)
val sommarstugan = Account(
AccountId("01920000-0000-7000-8000-0000000000b5"),
"Sommarstugan i Österåker 2027",
shares(6000),
"2026-10-01T09:00:00.000Z",
archived = false,
)
/** A custom category with a long name (long-content states). */
val renovation = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Renovering av sommarstugan", "build", ColorToken.AMBER, 110, archived = false)
val icaId = ExpenseId("01920000-0000-7000-8000-0000000000e1")
val systembolagetId = ExpenseId("01920000-0000-7000-8000-0000000000e2")
val slKortId = ExpenseId("01920000-0000-7000-8000-0000000000e3")
val hyraId = ExpenseId("01920000-0000-7000-8000-0000000000e4")
val ullaredId = ExpenseId("01920000-0000-7000-8000-0000000000e5")
val trananId = ExpenseId("01920000-0000-7000-8000-0000000000e6")
val snickareId = ExpenseId("01920000-0000-7000-8000-0000000000e7")
/** An id the phone does not hold (S-8 missing). */
val missingId = ExpenseId("01920000-0000-7000-8000-0000000000ef")
private val hyraTemplate = TemplateId("01920000-0000-7000-8000-0000000000f1")
private fun created(by: UserId?, at: String, source: AuditSource = AuditSource.APP) =
TimelineLine(if (by == null) null else if (by == jacob.id) jacob else anna, AuditAction.CREATE, source, at, emptyList(), pendingLocal = false)
private fun changed(by: UserId, at: String, vararg changes: FieldChange) =
TimelineLine(if (by == jacob.id) jacob else anna, AuditAction.UPDATE, AuditSource.APP, at, changes.toList(), pendingLocal = false)
private fun even(amount: Long) = listOf(Share(jacob.id, amount / 2, 5000), Share(anna.id, amount - amount / 2, 5000))
/** S-8 loaded + pending: Anna paid, account default 50/50, notes, created and changed twice, one change pending. */
val ica = FakeExpense(
id = icaId,
accountId = vardag.id,
categoryId = groceries.id,
amountOre = 124_760,
description = "ICA Maxi Lindhagen",
notes = "Storhandling inför middagen med Lindqvists",
date = "2026-10-16",
payer = anna.id,
mode = SplitMode.DEFAULT,
shares = even(124_760),
createdAt = "2026-10-16T12:02:00.000Z",
pending = true,
timeline = listOf(
created(anna.id, "2026-10-16T12:02:00.000Z"),
changed(jacob.id, "2026-10-16T17:40:00.000Z", FieldChange("amount_ore", "114760", "124760")),
changed(anna.id, "2026-10-16T18:05:00.000Z", FieldChange("category_id", "other", "groceries")),
),
)
/** History for the category suggestion: "Systembolaget" was Nöje before (CS-5 history exact). */
val systembolaget = FakeExpense(
id = systembolagetId,
accountId = vardag.id,
categoryId = entertainment.id,
amountOre = 41_200,
description = "Systembolaget",
notes = "",
date = "2026-10-10",
payer = jacob.id,
mode = SplitMode.DEFAULT,
shares = even(41_200),
createdAt = "2026-10-10T15:20:00.000Z",
timeline = listOf(created(jacob.id, "2026-10-10T15:20:00.000Z")),
)
/** Full split on Anna, paid by Jacob (S-7 full + notes in edit mode, S-8 full). */
val slKort = FakeExpense(
id = slKortId,
accountId = vardag.id,
categoryId = transport.id,
amountOre = 106_000,
description = "SL-kort 30 dagar, Anna",
notes = "Månadskort för oktober, köpt i SL-appen",
date = "2026-10-14",
payer = jacob.id,
mode = SplitMode.FULL,
shares = listOf(Share(jacob.id, 0, 0), Share(anna.id, 106_000, 10_000)),
createdAt = "2026-10-14T07:30:00.000Z",
timeline = listOf(created(jacob.id, "2026-10-14T07:30:00.000Z")),
)
/** Generated by the template "Hyra" (AC-8.11), later edited by Anna. */
val hyra = FakeExpense(
id = hyraId,
accountId = huset.id,
categoryId = housing.id,
amountOre = 1_450_000,
description = "Hyra september",
notes = "",
date = "2026-09-25",
payer = jacob.id,
mode = SplitMode.DEFAULT,
shares = listOf(Share(jacob.id, 870_000, 6000), Share(anna.id, 580_000, 4000)),
createdAt = "2026-09-25T04:00:00.000Z",
recurringTemplateId = hyraTemplate,
timeline = listOf(
created(null, "2026-09-25T04:00:00.000Z", AuditSource.RECURRING),
changed(anna.id, "2026-09-26T08:15:00.000Z", FieldChange("description", "Hyra", "Hyra september")),
),
)
/** Exact amounts: Jacob 400,00 kr, Anna 660,00 kr; Anna paid. */
val ullared = FakeExpense(
id = ullaredId,
accountId = vardag.id,
categoryId = shopping.id,
amountOre = 106_000,
description = "Ullared",
notes = "",
date = "2026-10-03",
payer = anna.id,
mode = SplitMode.EXACT,
shares = listOf(Share(jacob.id, 40_000, null), Share(anna.id, 66_000, null)),
createdAt = "2026-10-03T16:45:00.000Z",
timeline = listOf(created(anna.id, "2026-10-03T16:45:00.000Z")),
)
/** Custom ratio 70/30, moved from Vardag to Huset; the last change is waiting to sync (S-8 timeline). */
val tranan = FakeExpense(
id = trananId,
accountId = huset.id,
categoryId = restaurants.id,
amountOre = 156_000,
description = "Middag på Tranan",
notes = "",
date = "2026-10-12",
payer = jacob.id,
mode = SplitMode.RATIO,
shares = listOf(Share(jacob.id, 109_200, 7000), Share(anna.id, 46_800, 3000)),
createdAt = "2026-10-12T19:10:00.000Z",
pending = true,
timeline = listOf(
created(jacob.id, "2026-10-12T19:10:00.000Z"),
changed(
anna.id,
"2026-10-13T07:45:00.000Z",
FieldChange("amount_ore", "120000", "156000"),
FieldChange("account_id", "Vardag", "Huset"),
),
changed(jacob.id, "2026-10-13T20:30:00.000Z", FieldChange("split_mode", "default", "ratio")),
),
)
/** Long content: a 7-digit amount, a long description, account and category name, and a long history. */
val snickare = FakeExpense(
id = snickareId,
accountId = sommarstugan.id,
categoryId = renovation.id,
amountOre = 123_456_789,
description = "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet",
notes = "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.",
date = "2026-10-02",
payer = anna.id,
mode = SplitMode.DEFAULT,
shares = listOf(Share(jacob.id, 74_074_073, 6000), Share(anna.id, 49_382_716, 4000)),
createdAt = "2026-10-02T10:00:00.000Z",
pending = true,
timeline = listOf(
created(anna.id, "2026-10-02T10:00:00.000Z"),
changed(jacob.id, "2026-10-02T18:30:00.000Z", FieldChange("amount_ore", "120000000", "123456789")),
changed(anna.id, "2026-10-03T07:05:00.000Z", FieldChange("description", "Snickare", "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet")),
changed(anna.id, "2026-10-03T07:06:00.000Z", FieldChange("category_id", "home", "Renovering av sommarstugan")),
changed(jacob.id, "2026-10-04T21:15:00.000Z", FieldChange("expense_date", "2026-10-01", "2026-10-02"), FieldChange("payer_user_id", "Jacob", "Anna")),
changed(anna.id, "2026-10-05T08:00:00.000Z", FieldChange("notes", null, "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.")),
changed(jacob.id, "2026-10-06T12:40:00.000Z", FieldChange("account_id", "Huset", "Sommarstugan i Österåker 2027")),
),
)
private val hyraDraft = ExpenseDraft(null, huset.id, housing.id, 1_450_000, "Hyra", "", "2026-09-25", jacob.id, SplitMode.DEFAULT, null, null, null)
/** Loaded, online and synced except the pending rows; Vardag was the last account used, groceries its last category. */
val household: FakeScenario = FakeScenarios.empty.copy(
accounts = listOf(vardag, huset, japan, gotland),
expenses = listOf(ica, systembolaget, slKort, hyra, ullared, tranan),
templates = listOf(RecurringTemplate(hyraTemplate, hyraDraft, 25, "2026-01", null, paused = false, nextOccurrence = "2026-10-25")),
now = "2026-10-17T12:02:00.000Z",
defaultAccountId = vardag.id,
lastCategoryFor = mapOf(vardag.id to groceries.id, huset.id to housing.id),
)
/** The same household without a connection (S-7 saving still allowed, S-8 unchanged). */
val offline: FakeScenario = household.copy(online = false)
/** Long names and amounts: the long account and category exist, the long expense is in the long account. */
val long: FakeScenario = household.copy(
accounts = household.accounts + sommarstugan,
categories = household.categories + renovation,
expenses = household.expenses + snickare,
defaultAccountId = sommarstugan.id,
lastCategoryFor = household.lastCategoryFor + (sommarstugan.id to renovation.id),
)
/** ICA Maxi deleted by Anna on 17 October 09:12 (S-8 deleted, opened from a deletion notification). */
val deleted: FakeScenario = household.copy(
expenses = household.expenses.map { e ->
if (e.id != icaId) e else e.copy(
deleted = true,
pending = false,
timeline = e.timeline + TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false),
)
},
)
/**
* No category to fall back to (AC-8.6, v0.9): `other` is archived and nothing was used on this phone, so a new
* expense starts without a category until the user picks one.
*/
val noDefaultCategory: FakeScenario = household.copy(
categories = household.categories.map { if (it.id == other.id) it.copy(archived = true) else it },
expenses = emptyList(),
templates = emptyList(),
lastCategoryFor = emptyMap(),
)
}