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