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
@@ -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)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.2 KiB |
|
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(
|
||||
|
||||