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
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
@@ -130,18 +131,20 @@ fun Wordmark(modifier: Modifier = Modifier) {
}
}
/** The three destinations of the bottom navigation (SPEC §13.2). */
enum class KvittDestination { Home, Analytics, Settings }
/** The four destinations of the bottom navigation, in their order (SPEC §13.2, D-172: Kortköp between Översikt and Statistik). */
enum class KvittDestination { Home, Cards, Analytics, Settings }
/**
* The bottom navigation (design-spec §3.11): Material 3 1.4.0's stable `ShortNavigationBar` (64 dp), `surface-container`,
* indicator `primary-container`, filled icon when selected, labels label-medium capped at 1.6× (bold when selected).
* indicator `primary-container`, filled icon when selected, labels label-medium capped at 1.6× (bold when selected) and
* shrunk to fit their slot at large font scales (four destinations, D-172).
*/
@Composable
fun KvittNavigationBar(selected: KvittDestination, onSelect: (KvittDestination) -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val items = listOf(
Triple(KvittDestination.Home, KvittIcon.Home, R.string.nav_home),
Triple(KvittDestination.Cards, KvittIcon.CreditCard, R.string.nav_cards),
Triple(KvittDestination.Analytics, KvittIcon.BarChart, R.string.nav_analytics),
Triple(KvittDestination.Settings, KvittIcon.Settings, R.string.nav_settings),
)
@@ -154,10 +157,15 @@ fun KvittNavigationBar(selected: KvittDestination, onSelect: (KvittDestination)
onClick = { onSelect(destination) },
icon = { KvittIconImage(icon, null, filled = isSelected) },
label = {
// Four destinations since D-172: at large font scales a label that does not fit its slot
// ("Inställningar") shrinks to fit, down to NAVIGATION_LABEL_MIN of its size, instead of being cut.
val style = MaterialTheme.typography.labelMedium.capped(KvittFontCaps.NAVIGATION_LABEL).let { if (isSelected) it.copy(fontWeight = FontWeight.Bold) else it }
Text(
stringResource(name),
style = MaterialTheme.typography.labelMedium.capped(KvittFontCaps.NAVIGATION_LABEL).let { if (isSelected) it.copy(fontWeight = FontWeight.Bold) else it },
style = style,
maxLines = 1,
softWrap = false,
autoSize = TextAutoSize.StepBased(minFontSize = style.fontSize * KvittFontCaps.NAVIGATION_LABEL_MIN, maxFontSize = style.fontSize),
)
},
iconPosition = NavigationItemIconPosition.Top,
@@ -31,6 +31,7 @@ enum class KvittIcon(@param:DrawableRes val outline: Int, @param:DrawableRes val
CloudUpload(R.drawable.ic_cloud_upload, R.drawable.ic_cloud_upload_filled),
ContentCopy(R.drawable.ic_content_copy, R.drawable.ic_content_copy_filled),
ContentPaste(R.drawable.ic_content_paste, R.drawable.ic_content_paste_filled),
CreditCard(R.drawable.ic_credit_card, R.drawable.ic_credit_card_filled),
DateRange(R.drawable.ic_date_range, R.drawable.ic_date_range_filled),
Delete(R.drawable.ic_delete, R.drawable.ic_delete_filled),
Devices(R.drawable.ic_devices, R.drawable.ic_devices_filled),
@@ -276,4 +276,14 @@ object KvittDimens {
val settlementHeroVertical = 22.dp
val settlementHeroHorizontal = 20.dp
val settlementArrow = 28.dp
// Card artwork (S-23 to S-26, D-173, D-180): `CardArt` in :feature:cards. The bundled picture keeps its own aspect
// ratio (1313 × 829); the drawn fallback card is 1.586:1 with radius 16 and the name inset 16 from the lower left.
const val CARD_ART_ASPECT = 1.586f
val cardArtCorner = 16.dp
val cardArtNameInset = 16.dp
/** S-23 header card: the small artwork is 72 dp wide. */
val cardArtHeader = 72.dp
/** S-23 empty state and S-25, S-26: the large artwork is at most 280 dp wide. */
val cardArtLarge = 280.dp
}
@@ -88,6 +88,9 @@ data class KvittTypography(
/** Size limits of design-spec §2.7: these labels grow with the font scale only up to the factor given. */
object KvittFontCaps {
const val NAVIGATION_LABEL = 1.6f
/** A navigation label that does not fit its slot shrinks to fit, to at least this share of its size (D-172, K2). */
const val NAVIGATION_LABEL_MIN = 0.7f
const val SEESAW_NAME = 1.15f
const val ACCOUNT_TILE_LETTER = 1.3f
const val AVATAR_INITIALS = 1.3f
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols Rounded "credit_card", FILL 0, wght 500, GRAD 0, opsz 24 (Google, Apache License 2.0). Generated from the SVG; do not edit. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="@android:color/black"
android:pathData="M888.13-717.13v474.26q0 37.78-26.61 64.39t-64.39 26.61H162.87q-37.78 0-64.39-26.61t-26.61-64.39v-474.26q0-37.78 26.61-64.39t64.39-26.61h634.26q37.78 0 64.39 26.61t26.61 64.39Zm-725.26 76.41h634.26v-76.41H162.87v76.41Zm0 160v237.85h634.26v-237.85H162.87Zm0 237.85v-474.26 474.26Z" />
</group>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Symbols Rounded "credit_card", FILL 1, wght 600, GRAD 0, opsz 24 (Google, Apache License 2.0). Generated from the SVG; do not edit. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<group android:translateY="960">
<path
android:fillColor="@android:color/black"
android:pathData="M166.78-140.78q-44.3 0-75.15-30.85-30.85-30.85-30.85-75.15v-466.44q0-44.3 30.85-75.15 30.85-30.85 75.15-30.85h626.44q44.3 0 75.15 30.85 30.85 30.85 30.85 75.15v466.44q0 44.3-30.85 75.15-30.85 30.85-75.15 30.85H166.78Zm0-340.92h626.44v-160H166.78v160Z" />
</group>
</vector>
@@ -61,6 +61,7 @@
<!-- TalkBack of the date chip in S-7 and S-9 -->
<string name="common_date_spoken">Datum: %1$s</string>
<string name="nav_home">Översikt</string>
<string name="nav_cards">Kortköp</string>
<string name="nav_analytics">Statistik</string>
<string name="nav_settings">Inställningar</string>
<!-- TalkBack of the navigation bar -->
@@ -61,6 +61,7 @@
<!-- TalkBack of the date chip in S-7 and S-9 -->
<string name="common_date_spoken">Date: %1$s</string>
<string name="nav_home">Home</string>
<string name="nav_cards">Card purchases</string>
<string name="nav_analytics">Analytics</string>
<string name="nav_settings">Settings</string>
<!-- TalkBack of the navigation bar -->
@@ -12,7 +12,7 @@ import javax.xml.parsers.DocumentBuilderFactory
* (D-017, SPEC §14.7 Strings), with the CSV's English and Swedish text, plurals included.
*/
class StringsCatalogueTest {
private val features = listOf("onboarding_", "home_", "account_", "expense_", "settle_", "analytics_", "settings_")
private val features = listOf("onboarding_", "home_", "account_", "expense_", "settle_", "analytics_", "settings_", "cards_")
private data class Row(val key: String, val en: String, val sv: String, val notes: String)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,202 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.domain.CARD_MATCH_MAX
import se.hagstedt.kvitt.domain.CARD_MATCH_WINDOW_DAYS
import se.hagstedt.kvitt.domain.MatchableExpense
import se.hagstedt.kvitt.domain.MatchablePurchase
import se.hagstedt.kvitt.domain.matchCardPurchase
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnectStart
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardDay
import se.hagstedt.kvitt.model.CardDirection
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.CardPurchaseDetail
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardPurchaseRow
import se.hagstedt.kvitt.model.CardPurchaseStatus
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.CardsState
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseMatch
import se.hagstedt.kvitt.model.LinkedExpense
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SplitMode
import kotlin.math.abs
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
/**
* The fake of [CardRepository] (§14.2.4) over [FakeScenario.cards]: the same filters, order and CP-10 matches (through
* `:domain`'s [matchCardPurchase]) as `CardRepositoryImpl`, the CP-9 prefill, local link changes, and the online calls
* answering from the scenario ([FakeStore.online]).
*/
class FakeCardRepository(private val store: FakeStore) : CardRepository {
override val hasConnection: Flow<Boolean> = store.derive { it.activeConnection() != null }.distinctUntilChanged()
override fun cards(filter: CardFilter): Flow<CardsState> = store.derive { s -> s.cardsState(filter) }.distinctUntilChanged()
override fun purchase(id: CardPurchaseId): Flow<CardPurchaseDetail?> = store.derive { s ->
val p = s.cards.purchases.firstOrNull { it.id == id } ?: return@derive null
val toHandle = p.toHandle()
CardPurchaseDetail(CardPurchaseRow(s.model(p), if (toHandle) s.matches(p) else emptyList()), if (toHandle) s.others(p) else emptyList())
}.distinctUntilChanged()
override fun toHandle(): Flow<List<CardPurchase>> = store.derive { s -> s.newestFirst().filter { it.toHandle() }.map { s.model(it) } }.distinctUntilChanged()
override fun purchaseForExpense(id: ExpenseId): Flow<CardPurchase?> =
store.derive { s -> s.cards.purchases.firstOrNull { it.expenseId == id }?.let { s.model(it) } }.distinctUntilChanged()
override suspend fun providers(): OnlineResult<List<CardProvider>> {
if (store.current.online && store.current.cards.providersLoading) awaitCancellation()
return store.online { s -> s.cards.providersFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.cards.providers) }
}
override suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult<CardConnectStart> =
store.online { OnlineResult.Ok(CardConnectStart(ATTEMPT_ID, AUTH_URL, "2026-10-17T12:17:00.000Z")) }
override suspend fun attempt(attemptId: String): OnlineResult<CardAttemptState> = store.online { s ->
val attempt = s.cards.attempt
if (attempt is CardAttemptState.Connected) store.edit { it.copy(cards = it.cards.copy(connection = attempt.connection)) }
OnlineResult.Ok(attempt)
}
override suspend fun refresh(): OnlineResult<Unit> = store.online { s ->
if (s.activeConnection()?.status != CardConnectionStatus.ACTIVE) OnlineResult.Failed("card_consent_expired", null) else OnlineResult.Ok(Unit)
}
override suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult<Unit> = store.online { s ->
val c = s.cards.connection?.takeIf { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
if (days > c.historyDays) return@online OnlineResult.Failed("validation_failed", "history_days")
store.edit { it.copy(cards = it.cards.copy(connection = c.copy(historyDays = days))) }
OnlineResult.Ok(Unit)
}
/** CP-12: the purchases neither linked nor ignored go, the handled ones too with [eraseHandled]. */
override suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult<Unit> = store.online { s ->
val c = s.cards.connection?.takeIf { it.id == id } ?: return@online OnlineResult.Failed("not_found", null)
store.edit {
it.copy(
cards = it.cards.copy(
connection = c.copy(status = CardConnectionStatus.DISCONNECTED),
purchases = it.cards.purchases.filter { p -> !eraseHandled && (p.expenseId != null || p.ignored) },
),
)
}
OnlineResult.Ok(Unit)
}
/** CP-9 with the AC-8.6 default account and its category, as `CardRepositoryImpl.prefill`. */
override suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft? {
val s = store.current
val p = s.cards.purchases.firstOrNull { it.id == id }?.takeIf { it.direction == CardDirection.DEBIT } ?: return null
val active = s.accountsInOrder().filter { !it.archived }
val account = accountId?.takeIf { a -> active.any { it.id == a } } ?: s.defaultAccountId?.takeIf { a -> active.any { it.id == a } } ?: active.firstOrNull()?.id
val categories = s.categories.filter { !it.archived }
val category = account?.let { s.lastCategoryFor[it] }?.takeIf { c -> categories.any { it.id == c } } ?: categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }?.id
val description = p.merchant.trim().let { if (it.codePointCount(0, it.length) <= DESCRIPTION_MAX) it else it.substring(0, it.offsetByCodePoints(0, DESCRIPTION_MAX)) }
return ExpenseDraft(null, account, category, p.amountOre, description, "", p.date, s.me.id, SplitMode.DEFAULT, null, null, null)
}
override suspend fun link(id: CardPurchaseId, expenseId: ExpenseId) = change(id) { it.copy(expenseId = expenseId, ignored = false) }
override suspend fun unlink(id: CardPurchaseId) = change(id) { it.copy(expenseId = null) }
override suspend fun setIgnored(id: CardPurchaseId, ignored: Boolean) = change(id) { it.copy(ignored = ignored, expenseId = if (ignored) null else it.expenseId) }
private fun change(id: CardPurchaseId, edit: (FakeCardPurchase) -> FakeCardPurchase) = store.edit { s ->
s.copy(cards = s.cards.copy(purchases = s.cards.purchases.map { if (it.id == id && it.direction == CardDirection.DEBIT) edit(it).copy(pendingLocal = true) else it }))
}
companion object {
const val ATTEMPT_ID = "01920000-0000-7000-8000-0000000000aa"
const val AUTH_URL = "https://bank.example.org/authorize"
private const val DESCRIPTION_MAX = 100
private const val OTHERS_WINDOW_DAYS = 14
}
private fun FakeScenario.activeConnection(): CardConnection? = cards.connection?.takeIf { it.status != CardConnectionStatus.DISCONNECTED }
private fun FakeScenario.cardsState(filter: CardFilter): CardsState {
val connection = activeConnection()
val provider = connection?.let { c -> cards.providers.firstOrNull { it.id == c.providerId } ?: CardProvider(c.providerId, "", "", c.providerId, true) } ?: cards.providers.firstOrNull()
if (connection == null) return CardsState(provider, null, filter, CardFilter.entries.associateWith { 0 }, emptyList(), cards.fetching)
val rows = newestFirst().filter { it.inFilter(filter) }.map { CardPurchaseRow(model(it), if (filter == CardFilter.TO_HANDLE) matches(it) else emptyList()) }
return CardsState(
provider = provider,
connection = connection.copy(expiresSoon = expiresSoon(connection)),
filter = filter,
counts = CardFilter.entries.associateWith { f -> cards.purchases.count { it.inFilter(f) } },
days = rows.groupBy { it.purchase.date }.map { (date, inDay) -> CardDay(date, inDay) },
fetching = cards.fetching,
)
}
/** CP-6: ACTIVE and `valid_until` within 14 days of the scenario's clock. */
private fun FakeScenario.expiresSoon(c: CardConnection): Boolean {
val until = c.validUntil?.let { Instant.parse(it) } ?: return false
return c.status == CardConnectionStatus.ACTIVE && until - Instant.parse(now) <= EXPIRY_NOTICE.days
}
private fun FakeScenario.newestFirst() =
cards.purchases.sortedWith(compareByDescending<FakeCardPurchase> { it.date }.thenByDescending { it.createdAt }.thenByDescending { it.id.value })
private fun FakeScenario.model(p: FakeCardPurchase) = CardPurchase(
id = p.id,
connectionId = cards.connection?.id ?: CardConnectionId(""),
status = p.status,
direction = p.direction,
date = p.date,
bookingDate = if (p.status == CardPurchaseStatus.BOOKED) p.date else null,
amountOre = p.amountOre,
originalAmount = p.originalAmount,
originalCurrency = p.originalCurrency,
merchant = p.merchant,
linked = p.expenseId?.let { id -> expenses.firstOrNull { it.id == id && !it.deleted }?.let { LinkedExpense(it.id, it.description, account(it.accountId)?.name.orEmpty()) } },
ignored = p.ignored,
pendingLocal = p.pendingLocal,
)
private fun FakeScenario.linkedByOther(expense: ExpenseId, purchase: CardPurchaseId) = cards.purchases.any { it.id != purchase && it.expenseId == expense }
private fun FakeScenario.matches(p: FakeCardPurchase): List<ExpenseMatch> {
val live = expenses.filter { !it.deleted }
val candidates = live.map { MatchableExpense(it.id.value, it.amountOre, LocalDate.parse(it.date), it.createdAt, linkedByOther(it.id, p.id)) }
val ids = matchCardPurchase(MatchablePurchase(p.amountOre, LocalDate.parse(p.date), p.direction.name.lowercase()), candidates, CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
return ids.mapNotNull { id -> live.firstOrNull { it.id.value == id }?.let { match(it) } }
}
private fun FakeScenario.others(p: FakeCardPurchase): List<ExpenseMatch> {
val day = LocalDate.parse(p.date).toEpochDays()
return expenses.filter { !it.deleted && !linkedByOther(it.id, p.id) && abs(LocalDate.parse(it.date).toEpochDays() - day) <= OTHERS_WINDOW_DAYS }
.sortedWith(compareByDescending<FakeExpense> { it.date }.thenByDescending { it.createdAt }.thenBy { it.id.value })
.mapNotNull { match(it) }
}
private fun FakeScenario.match(e: FakeExpense): ExpenseMatch? {
val payer = user(e.payer) ?: return null
return ExpenseMatch(e.id, e.description, account(e.accountId)?.name.orEmpty(), payer, e.date, e.amountOre)
}
}
private const val EXPIRY_NOTICE = 14
private fun FakeCardPurchase.toHandle() = direction == CardDirection.DEBIT && expenseId == null && !ignored
private fun FakeCardPurchase.inFilter(filter: CardFilter) = when (filter) {
CardFilter.TO_HANDLE -> toHandle()
CardFilter.LINKED -> expenseId != null
CardFilter.IGNORED -> ignored
CardFilter.ALL -> true
}
@@ -120,8 +120,12 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
override suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? =
store.current.suggestCategory(accountId, description)
/** A soft delete; a card purchase linked to it is "Att hantera" again (CP-9). */
override suspend fun delete(id: ExpenseId) = store.edit { s ->
s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
s.copy(
expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it },
cards = s.cards.copy(purchases = s.cards.purchases.map { if (it.expenseId == id) it.copy(expenseId = null) else it }),
)
}
override fun detail(id: ExpenseId): Flow<ExpenseDetailState?> = store.derive { s ->
@@ -19,4 +19,5 @@ class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
val update = FakeUpdateRepository(store)
val about = FakeAboutRepository(store)
val connectivity = FakeConnectivityRepository(store)
val cards = FakeCardRepository(store)
}
@@ -3,6 +3,12 @@ package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardDirection
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardPurchaseStatus
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Device
@@ -68,6 +74,7 @@ data class FakeSettlement(
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
* @property insightsFailure the code the Insights reads fail with while online (`network`: the server could not be
* reached); null: they succeed (D-141).
* @property cards the viewer's card catalogue, connection and purchases (S-23 to S-27, D-173); none by default.
*/
data class FakeScenario(
val me: User,
@@ -95,6 +102,44 @@ data class FakeScenario(
val lastAttemptFailed: Boolean = false,
val pairingInviteKind: InviteKind? = null,
val insightsFailure: String? = null,
val cards: FakeCards = FakeCards(),
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
/** A card purchase as the fakes hold it: the stored row of §14.2.1 in model types; [expenseId] the link (CP-9). */
data class FakeCardPurchase(
val id: CardPurchaseId,
val date: String,
val amountOre: Long?,
val merchant: String,
val status: CardPurchaseStatus = CardPurchaseStatus.BOOKED,
val direction: CardDirection = CardDirection.DEBIT,
val originalAmount: String? = null,
val originalCurrency: String? = null,
val expenseId: ExpenseId? = null,
val ignored: Boolean = false,
val pendingLocal: Boolean = false,
val createdAt: String = date + "T12:00:00.000Z",
)
/**
* The viewer's cards (D-161: only the holder's rows exist on the phone). [connection] null: no connection (the empty
* state); its `expiresSoon` is derived from the scenario's clock (CP-6). [providersFailure]: the code E-36 fails with
* while online (`network`: "Kunde inte nå servern"); [providersLoading]: E-36 never answers (S-24's skeleton).
* [attempt]: what E-38 answers; [fetching]: an E-42 call is running.
*/
data class FakeCards(
val providers: List<CardProvider> = listOf(SJ_PRIO),
val providersFailure: String? = null,
val providersLoading: Boolean = false,
val connection: CardConnection? = null,
val purchases: List<FakeCardPurchase> = emptyList(),
val attempt: CardAttemptState = CardAttemptState.Pending,
val fetching: Boolean = false,
) {
companion object {
/** The registry's only entry (CP-1). */
val SJ_PRIO = CardProvider("sj_prio_mastercard", "SJ Prio Mastercard", "SEB Kort", "sj_prio_mastercard", available = true)
}
}
@@ -0,0 +1,132 @@
package se.hagstedt.kvitt.fakes.cards
import se.hagstedt.kvitt.fakes.FakeCardPurchase
import se.hagstedt.kvitt.fakes.FakeCards
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardDirection
import se.hagstedt.kvitt.model.CardFetchError
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardPurchaseStatus
import se.hagstedt.kvitt.model.DefaultCategoryKey
/**
* Card purchases (S-23 to S-27) on [HomeScenarios.household]: Jacob's SJ Prio Mastercard, connected for 30 days of
* history, valid until 22 April 2027, fetched 15 minutes before the clock (17 October 2026 14:02). The purchases cover
* every row of S-23: one match (Anna's ICA Maxi entry, the Systembolaget expense), two matches, none, pending, a foreign
* currency, linked, ignored and a credit. Matches come from `:domain` (§6.2).
*/
object CardScenarios {
private val jacob = HomeScenarios.household.me.id
private val anna = HomeScenarios.household.partner?.id ?: jacob
private fun id(n: Int) = CardPurchaseId("01920000-0000-7000-8000-0000000c" + n.toString(16).padStart(4, '0'))
val connectionId = CardConnectionId("01920000-0000-7000-8000-0000000cc001")
val connection = CardConnection(
id = connectionId,
providerId = FakeCards.SJ_PRIO.id,
status = CardConnectionStatus.ACTIVE,
historyDays = 30,
validUntil = "2027-04-22T10:00:00.000Z",
lastFetchAt = "2026-10-17T11:47:00.000Z",
lastFetchError = null,
expiresSoon = false,
)
/** A second household expense of 384,40 kr next to Hemköp's, so one purchase has two possible expenses. */
private val apotek = HomeScenarios.expense(20, HomeScenarios.vardag, DefaultCategoryKey.HEALTH, 38_440, "Apotek Hjärtat", "2026-10-12", anna)
val ica = FakeCardPurchase(id(1), "2026-10-16", 124_760, "ICA MAXI LINDHAGEN")
val systembolaget = FakeCardPurchase(id(2), "2026-10-16", 41_200, "SYSTEMBOLAGET 0152 STHLM", status = CardPurchaseStatus.PENDING)
val pressbyran = FakeCardPurchase(id(3), "2026-10-17", 5_400, "PRESSBYRAN ODENPLAN", status = CardPurchaseStatus.PENDING)
val twoMatches = FakeCardPurchase(id(4), "2026-10-11", 38_440, "KLARNA*HEMKOP")
val wolt = FakeCardPurchase(id(5), "2026-10-13", 28_900, "WOLT")
val amazon = FakeCardPurchase(id(6), "2026-10-12", null, "AMAZON.DE", originalAmount = "24.99", originalCurrency = "EUR")
val willys = FakeCardPurchase(id(7), "2026-09-28", 52_300, "WILLYS HORNSTULL", expenseId = HomeScenarios.willys.id)
val bauhaus = FakeCardPurchase(id(8), "2026-09-20", 282_320, "BAUHAUS BARKARBY", expenseId = HomeScenarios.bauhaus.id)
val spotify = FakeCardPurchase(id(9), "2026-10-10", 11_900, "SPOTIFY P2F3A1", ignored = true)
val payment = FakeCardPurchase(id(10), "2026-10-02", 500_000, "BETALNING TACK", direction = CardDirection.CREDIT)
private val all = listOf(ica, systembolaget, pressbyran, twoMatches, wolt, amazon, willys, bauhaus, spotify, payment)
private val base: FakeScenario = HomeScenarios.household.copy(expenses = HomeScenarios.household.expenses + apotek)
/** No connection: S-23's empty state "Anslut ditt kort"; S-24 lists the catalogue. */
val none: FakeScenario = base
/** Connected, the first fetch after BankID still running: "Hämtar dina köp…". */
val firstFetch: FakeScenario = base.copy(cards = FakeCards(connection = connection.copy(lastFetchAt = null)))
/** Connected with every kind of row. */
val loaded: FakeScenario = base.copy(cards = FakeCards(connection = connection, purchases = all))
/** Connected, nothing to handle: "Inget att hantera". */
val nothingToHandle: FakeScenario = base.copy(cards = FakeCards(connection = connection, purchases = listOf(willys, bauhaus, spotify)))
/** Connected, no purchases at all: every filter is empty. */
val noPurchases: FakeScenario = base.copy(cards = FakeCards(connection = connection))
/** [loaded] offline: "Uppdatera" disabled; linking and ignoring still work (CP-9). */
val offline: FakeScenario = loaded.copy(online = false)
/** An E-42 call of this phone running: "Uppdaterar…". */
val refreshing: FakeScenario = loaded.copy(cards = loaded.cards.copy(fetching = true))
/** The consent ends in ten days (CP-6): "Kortanslutningen upphör …" with "Förnya med BankID". */
val expiring: FakeScenario = loaded.copy(cards = loaded.cards.copy(connection = connection.copy(validUntil = "2026-10-27T10:00:00.000Z")))
/** The consent has ended: "Kortanslutningen har upphört"; the list is kept and still works. */
val expired: FakeScenario = loaded.copy(
cards = loaded.cards.copy(connection = connection.copy(status = CardConnectionStatus.EXPIRED, validUntil = "2026-10-15T10:00:00.000Z", lastFetchError = CardFetchError.CONSENT_EXPIRED)),
)
/** The last fetch failed (CP-14): "Kunde inte hämta köp. Senast uppdaterad …" with "Försök igen". */
val fetchFailed: FakeScenario = loaded.copy(
cards = loaded.cards.copy(connection = connection.copy(lastFetchAt = "2026-10-16T19:45:00.000Z", lastFetchError = CardFetchError.BANK_UNAVAILABLE)),
)
/** Long merchant texts and 7-digit amounts. */
val long: FakeScenario = base.copy(
cards = FakeCards(
connection = connection,
purchases = listOf(
FakeCardPurchase(id(21), "2026-10-17", 1_234_567_89, "BYGGMAX STORA INKÖPSCENTRET HÄGERSTEN ÅRSTABERG STOCKHOLM", status = CardPurchaseStatus.PENDING),
FakeCardPurchase(id(22), "2026-10-16", 124_760, "ICA MAXI LINDHAGEN STOCKHOLM KUNGSHOLMEN SANKT ERIKSGATAN"),
FakeCardPurchase(id(23), "2026-10-15", 9_876_543_21, "RESEBYRÅN SKANDINAVISKA FJÄLLRESOR OCH ÄVENTYR AKTIEBOLAG"),
FakeCardPurchase(id(24), "2026-10-12", null, "AMAZON MARKETPLACE EU SARL LUXEMBOURG", originalAmount = "1234.56", originalCurrency = "EUR"),
),
),
)
// S-24 and S-25
/** [none] with the provider not configured for the viewer on the server (CP-13). */
val unavailable: FakeScenario = none.copy(cards = FakeCards(providers = listOf(FakeCards.SJ_PRIO.copy(available = false))))
/** A catalogue entry with an image key the app has no artwork for: the drawn fallback card (D-180). */
val unknownArtwork: FakeScenario = none.copy(
cards = FakeCards(providers = listOf(FakeCards.SJ_PRIO, CardProvider("nordea_visa", "Nordea Visa", "Nordea", "nordea_visa", available = true))),
)
/** E-36 fails: "Kunde inte nå servern" with "Försök igen". */
val providersFailed: FakeScenario = none.copy(cards = FakeCards(providersFailure = "network"))
/** E-36 has not answered: the skeleton tile. */
val providersLoading: FakeScenario = none.copy(cards = FakeCards(providersLoading = true))
/** [none] offline: S-24's tiles and S-25's button disabled with "Kräver anslutning". */
val connectOffline: FakeScenario = none.copy(online = false)
/** E-38 answers per state (S-25). */
val attemptPending: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Pending))
val attemptConnected: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Connected(connection.copy(lastFetchAt = null))))
val attemptFailed: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Failed("cancelled")))
val attemptExpired: FakeScenario = none.copy(cards = FakeCards(attempt = CardAttemptState.Expired))
}
@@ -0,0 +1,133 @@
package se.hagstedt.kvitt.model
import kotlinx.coroutines.flow.Flow
// Card purchases (S-23 to S-27, FR-22, §9.12), SPEC §14.2.4 "Changes in v0.35". Only the signed-in user's own card rows
// exist on the phone (D-161). Wire values of the enums are their lowercase names.
@JvmInline value class CardConnectionId(val value: String)
@JvmInline value class CardPurchaseId(val value: String)
enum class CardConnectionStatus { ACTIVE, EXPIRED, DISCONNECTED }
enum class CardPurchaseStatus { PENDING, BOOKED }
enum class CardDirection { DEBIT, CREDIT }
enum class CardFilter { TO_HANDLE, LINKED, IGNORED, ALL }
/** Wire: lowercase; an unknown value reads as [FAILED] (CP-14). */
enum class CardFetchError { BANK_UNAVAILABLE, RATE_LIMITED, CONSENT_EXPIRED, FAILED }
/** An entry of the card catalogue (E-36, CP-1); [id] and [imageKey] are open strings. */
data class CardProvider(val id: String, val name: String, val issuer: String, val imageKey: String, val available: Boolean)
/** [expiresSoon]: [status] ACTIVE and [validUntil] within 14 days (CP-6), computed with the phone's clock. */
data class CardConnection(
val id: CardConnectionId,
val providerId: String,
val status: CardConnectionStatus,
val historyDays: Int,
val validUntil: String?,
val lastFetchAt: String?,
val lastFetchError: CardFetchError?,
val expiresSoon: Boolean,
)
data class LinkedExpense(val id: ExpenseId, val description: String, val accountName: String)
/** [amountOre] null: foreign currency (CP-7); [linked] null also while the linked expense is not on the phone. */
data class CardPurchase(
val id: CardPurchaseId,
val connectionId: CardConnectionId,
val status: CardPurchaseStatus,
val direction: CardDirection,
val date: String,
val bookingDate: String?,
val amountOre: Long?,
val originalAmount: String?,
val originalCurrency: String?,
val merchant: String,
val linked: LinkedExpense?,
val ignored: Boolean,
val pendingLocal: Boolean,
)
data class ExpenseMatch(val id: ExpenseId, val description: String, val accountName: String, val payer: User, val date: String, val amountOre: Long)
/** [matches] (CP-10) only under [CardFilter.TO_HANDLE], else empty. */
data class CardPurchaseRow(val purchase: CardPurchase, val matches: List<ExpenseMatch>)
data class CardDay(val date: String, val rows: List<CardPurchaseRow>)
/** [connection] null: the empty state; [fetching]: an E-42 call of this phone is running. */
data class CardsState(
val provider: CardProvider?,
val connection: CardConnection?,
val filter: CardFilter,
val counts: Map<CardFilter, Int>,
val days: List<CardDay>,
val fetching: Boolean,
)
/** [others]: "Välj en annan utgift" (CP-10). */
data class CardPurchaseDetail(val row: CardPurchaseRow, val others: List<ExpenseMatch>)
data class CardConnectStart(val attemptId: String, val authUrl: String, val expiresAt: String)
sealed interface CardAttemptState {
data object Pending : CardAttemptState
data class Connected(val connection: CardConnection) : CardAttemptState
/** [reason]: the E-38 failure token, for example "no_account", "bank_error", "cancelled". */
data class Failed(val reason: String) : CardAttemptState
data object Expired : CardAttemptState
}
interface CardRepository {
/** A connection with status ACTIVE or EXPIRED exists (S-5's "Från kortköp"). */
val hasConnection: Flow<Boolean>
/** S-23, from Room; works offline. */
fun cards(filter: CardFilter): Flow<CardsState>
/** The purchase sheet. */
fun purchase(id: CardPurchaseId): Flow<CardPurchaseDetail?>
/** S-27: debit, neither linked nor ignored, newest first. */
fun toHandle(): Flow<List<CardPurchase>>
/** S-8's line. */
fun purchaseForExpense(id: ExpenseId): Flow<CardPurchase?>
/** E-36, online only (S-24). */
suspend fun providers(): OnlineResult<List<CardProvider>>
/** E-37, connect and renew. */
suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult<CardConnectStart>
/** E-38. */
suspend fun attempt(attemptId: String): OnlineResult<CardAttemptState>
/** E-42, then a card pull. */
suspend fun refresh(): OnlineResult<Unit>
/** E-41, shorter only. */
suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult<Unit>
/** E-43. */
suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult<Unit>
/** CP-9; [accountId] null: the AC-8.6 default; null when the purchase is unknown. */
suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft?
/** Local at once, sent later (CP-9); never fails. */
suspend fun link(id: CardPurchaseId, expenseId: ExpenseId)
suspend fun unlink(id: CardPurchaseId)
suspend fun setIgnored(id: CardPurchaseId, ignored: Boolean)
}
@@ -42,6 +42,17 @@ class ContractTest {
)
}
/** §14.3.3 card wire values (v0.35, D-167): `CardConnection.status`, `CardPurchase.status` and `direction`, `last_fetch_error`. */
@Test
fun cardEnumWireValuesMatchTheSpec() {
assertEquals(listOf("active", "expired", "disconnected"), wire(CardConnectionStatus.entries.toTypedArray()))
assertEquals(listOf("pending", "booked"), wire(CardPurchaseStatus.entries.toTypedArray()))
assertEquals(listOf("debit", "credit"), wire(CardDirection.entries.toTypedArray()))
assertEquals(listOf("bank_unavailable", "rate_limited", "consent_expired", "failed"), wire(CardFetchError.entries.toTypedArray()))
// S-23's filters in the order of the chips: "Att hantera" (default), "Kopplade", "Ignorerade", "Alla".
assertEquals(listOf("to_handle", "linked", "ignored", "all"), wire(CardFilter.entries.toTypedArray()))
}
@Test
fun domainErrorTokensMatchSpec14_5_3() {
assertEquals(