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,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))
}