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