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,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(