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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user