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:
+266
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user