K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)

- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values.
- :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest.
- :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test,
  2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of
  pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with
  CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks.
- :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27.
- :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while
  resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn
  fallback; 135 goldens and state-holder tests.
- :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line;
  :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort".
- :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the
  bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings.
- docs/ux/strings.csv: the card keys of §13.3 plus K2's additions.

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-24 16:49:47 +02:00
parent 07df30d04d
commit a8f91ee6ea
391 changed files with 6889 additions and 54 deletions
@@ -0,0 +1,37 @@
package se.hagstedt.kvitt.domain
import kotlinx.datetime.LocalDate
import kotlin.math.abs
// Card purchase matches, SPEC §14.5.8 (the rule of §9.12 CP-10, D-170). Pure: dates are passed in, no clock.
/** A purchase to match: [direction] is the wire value `debit` or `credit`; [amountOre] null for a foreign currency. */
data class MatchablePurchase(val amountOre: Long?, val date: LocalDate, val direction: String)
/** A non-deleted household expense; [linkedByViewer]: another purchase of the viewer already links it. */
data class MatchableExpense(val id: String, val amountOre: Long, val date: LocalDate, val createdAt: String, val linkedByViewer: Boolean)
/** CP-10 calls [matchCardPurchase] with these. */
const val CARD_MATCH_WINDOW_DAYS = 3
const val CARD_MATCH_MAX = 3
/**
* The ids of the [expenses] with the purchase's amount, `|date − purchase.date| ≤ windowDays` and not linked by the
* viewer, ordered by the absolute day difference, then `createdAt` descending, then id ascending, at most [max]. Empty
* for a `credit` purchase or a null amount. Callers pass non-deleted expenses only.
*/
fun matchCardPurchase(purchase: MatchablePurchase, expenses: List<MatchableExpense>, windowDays: Int, max: Int): List<String> {
val amount = purchase.amountOre ?: return emptyList()
if (purchase.direction != DEBIT) return emptyList()
val day = purchase.date.toEpochDays()
return expenses.asSequence()
.filter { it.amountOre == amount && !it.linkedByViewer }
.map { it to abs(it.date.toEpochDays() - day) }
.filter { (_, distance) -> distance <= windowDays }
.sortedWith(compareBy<Pair<MatchableExpense, Long>> { it.second }.thenByDescending { it.first.createdAt }.thenBy { it.first.id })
.take(max.coerceAtLeast(0))
.map { it.first.id }
.toList()
}
private const val DEBIT = "debit"
@@ -0,0 +1,58 @@
package se.hagstedt.kvitt.domain
import kotlinx.datetime.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
/** SPEC §9.12 CP-10 and §14.5.8 through [matchCardPurchase]; the shared cases are `match_card_purchase.json` (VectorTest). */
class CardMatchTest {
private val day = LocalDate(2026, 10, 16)
private fun purchase(amount: Long? = 41_200, date: LocalDate = day, direction: String = "debit") = MatchablePurchase(amount, date, direction)
private fun expense(id: String, amount: Long = 41_200, date: LocalDate = day, createdAt: String = "2026-10-16T10:00:00.000Z", linked: Boolean = false) =
MatchableExpense(id, amount, date, createdAt, linked)
private fun match(p: MatchablePurchase, vararg e: MatchableExpense) = matchCardPurchase(p, e.toList(), CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
@Test
fun sameAmountWithinThreeDaysMatches_fourDaysDoesNot() {
assertEquals(
listOf("a", "b", "c"),
match(
purchase(),
expense("a", date = day),
expense("b", date = LocalDate(2026, 10, 13)),
expense("c", date = LocalDate(2026, 10, 19)),
expense("d", date = LocalDate(2026, 10, 20)),
),
)
}
@Test
fun aDifferentAmountOrAnExpenseLinkedByTheViewerIsLeftOut() {
assertEquals(emptyList<String>(), match(purchase(), expense("a", amount = 41_201), expense("b", linked = true)))
}
@Test
fun orderedByDayDifferenceThenNewestCreatedThenId_cutAtMax() {
val result = match(
purchase(),
expense("z", date = LocalDate(2026, 10, 17), createdAt = "2026-10-17T08:00:00.000Z"),
expense("y", date = LocalDate(2026, 10, 15), createdAt = "2026-10-17T09:00:00.000Z"),
expense("b", date = day, createdAt = "2026-10-16T08:00:00.000Z"),
expense("a", date = day, createdAt = "2026-10-16T08:00:00.000Z"),
)
assertEquals(listOf("a", "b", "y"), result)
}
@Test
fun aCreditOrAForeignPurchaseHasNoMatches() {
assertEquals(emptyList<String>(), match(purchase(direction = "credit"), expense("a")))
assertEquals(emptyList<String>(), match(purchase(amount = null), expense("a")))
}
@Test
fun theWindowCrossesAYearBoundary() {
val p = purchase(date = LocalDate(2027, 1, 1))
assertEquals(listOf("a"), match(p, expense("a", date = LocalDate(2026, 12, 29)), expense("b", date = LocalDate(2026, 12, 28))))
}
}
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */
/** The vector directory holds exactly the files of SPEC §14.5.5 ([VECTOR_FUNCTIONS]); a missing or untested file fails. */
class VectorFilesTest {
@Test
fun everyVectorFileIsPresentAndTested() {
@@ -50,6 +50,7 @@ class VectorTest(private val case: VectorCase, private val label: String) {
"spend_series" -> series(input)
"resolve_range" -> range(input)
"suggest_category" -> suggest(input)
"match_card_purchase" -> matchCard(input)
else -> error("no runner for $function")
}
@@ -164,6 +165,18 @@ class VectorTest(private val case: VectorCase, private val label: String) {
return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull)
}
/** §14.5.8: input `purchase` {amount_ore, date, direction}, `expenses`, `window_days`, `max`; output {"ok": [ids]}. */
private fun matchCard(input: JsonObject): JsonElement {
val p = input.getValue("purchase").jsonObject
val purchase = MatchablePurchase(p.longOrNull("amount_ore"), LocalDate.parse(p.str("date")), p.str("direction"))
val expenses = input.objects("expenses").map {
MatchableExpense(it.str("id"), it.long("amount_ore"), LocalDate.parse(it.str("date")), it.str("created_at"), it.bool("linked_by_viewer"))
}
val window = input.getValue("window_days").jsonPrimitive.content.toInt()
val max = input.getValue("max").jsonPrimitive.content.toInt()
return ok(strings(matchCardPurchase(purchase, expenses, window, max)))
}
private fun range(input: JsonObject): JsonElement {
val preset = RangePreset.valueOf(input.str("preset").uppercase())
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
@@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */
/** The vector files of SPEC §14.5.5 (with suggest_category, UX-3, and match_card_purchase, §14.5.8), keyed by function name. */
val VECTOR_FUNCTIONS: List<String> = listOf(
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
"spend_series", "resolve_range", "suggest_category",
"spend_series", "resolve_range", "suggest_category", "match_card_purchase",
)
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)
@@ -46,6 +46,7 @@ object Vectors {
internal fun JsonObject.str(key: String): String = getValue(key).jsonPrimitive.content
internal fun JsonObject.strOrNull(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.content
internal fun JsonObject.long(key: String): Long = getValue(key).jsonPrimitive.long
internal fun JsonObject.longOrNull(key: String): Long? = (get(key) as? JsonPrimitive)?.takeIf { it !is JsonNull }?.long
internal fun JsonObject.bool(key: String): Boolean = getValue(key).jsonPrimitive.boolean
internal fun JsonObject.strings(key: String): List<String> = getValue(key).jsonArray.map { it.jsonPrimitive.content }
internal fun JsonObject.longMap(key: String): Map<String, Long> = getValue(key).jsonObject.mapValues { it.value.jsonPrimitive.long }