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
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,12 @@ object ErrorCodes {
const val INTERNAL = "internal"
const val UNAVAILABLE = "unavailable"
// Card purchases (§14.3.2, v0.35).
const val CARDS_UNAVAILABLE = "cards_unavailable"
const val CARD_CONSENT_EXPIRED = "card_consent_expired"
const val CARD_BANK_UNAVAILABLE = "card_bank_unavailable"
const val EXPENSE_ALREADY_LINKED = "expense_already_linked"
/** Not a server code: the network failure marker of `sync_state.last_error_code` and of REST calls (§9.9). */
const val NETWORK = "network"
@@ -0,0 +1,105 @@
package se.hagstedt.kvitt.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Card purchases, SPEC §14.3.3 wire objects `CardProvider`, `CardConnection`, `CardPurchase`, `CardAttempt` and the
// bodies of E-36 to E-44 (§14.3.4, v0.35). Enum values stay strings and are mapped at the edge (an unknown value from a
// newer server is not a crash). Only ever sent to the holder's own devices (CP-11); never logged (§8.7).
/** `CardProvider` (E-36, D-160). */
@Serializable
data class CardProviderDto(
val id: String,
val name: String,
val issuer: String,
@SerialName("image_key") val imageKey: String,
val available: Boolean,
)
/** E-36 response. */
@Serializable
data class CardProvidersResponse(val providers: List<CardProviderDto>)
/** `CardConnection` (E-38, E-40, E-41, E-42, E-43; D-167). No session id, account uid or provider reference. */
@Serializable
data class CardConnectionDto(
val id: String,
@SerialName("provider_id") val providerId: String,
val status: String,
@SerialName("history_days") val historyDays: Int,
@SerialName("valid_until") val validUntil: String?,
@SerialName("last_fetch_at") val lastFetchAt: String?,
@SerialName("last_fetch_error") val lastFetchError: String?,
val fetching: Boolean,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("card_version") val cardVersion: Long,
)
/** `CardPurchase` (E-40, E-44; D-167, D-168). A tombstone has `deleted_at` set and its content erased. */
@Serializable
data class CardPurchaseDto(
val id: String,
@SerialName("connection_id") val connectionId: String,
val status: String,
val direction: String,
@SerialName("purchase_date") val purchaseDate: String,
@SerialName("booking_date") val bookingDate: String?,
@SerialName("amount_ore") val amountOre: Long?,
@SerialName("original_amount") val originalAmount: String?,
@SerialName("original_currency") val originalCurrency: String?,
val merchant: String,
@SerialName("expense_id") val expenseId: String?,
val ignored: Boolean,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String,
@SerialName("deleted_at") val deletedAt: String?,
@SerialName("card_version") val cardVersion: Long,
)
/** `CardAttempt` (E-37, E-38; D-162): `auth_url` only in E-37's answer, `connection` once connected. */
@Serializable
data class CardAttemptDto(
val id: String,
@SerialName("provider_id") val providerId: String,
val status: String,
val failure: String?,
@SerialName("auth_url") val authUrl: String?,
@SerialName("expires_at") val expiresAt: String,
val connection: CardConnectionDto?,
)
/** E-37 request; `client` is `android` from the app (CP-2). */
@Serializable
data class CardAuthRequest(
@SerialName("provider_id") val providerId: String,
@SerialName("history_days") val historyDays: Int,
val client: String,
)
/** E-40 response: the caller's card rows with `card_version > since`, tombstones included. */
@Serializable
data class CardSyncPage(
val since: Long,
@SerialName("next_since") val nextSince: Long,
@SerialName("has_more") val hasMore: Boolean,
@SerialName("server_time") val serverTime: String,
val connections: List<CardConnectionDto>,
val purchases: List<CardPurchaseDto>,
)
/** E-41 request: only a value at most the current one (CP-3). */
@Serializable
data class PatchCardConnectionRequest(@SerialName("history_days") val historyDays: Int)
/** E-42 response; `fetched` false when the 60-second rule answered or the fetch still runs (`connection.fetching`). */
@Serializable
data class CardRefreshResponse(val connection: CardConnectionDto, val fetched: Boolean)
/** E-44 request: both keys required, `expense_id` null clears the link (CP-9). */
@Serializable
data class CardPurchaseUpdateRequest(
@SerialName("expense_id") val expenseId: String?,
val ignored: Boolean,
)
@@ -15,6 +15,7 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.encodeURLPathPart
import io.ktor.http.isSuccess
import io.ktor.serialization.JsonConvertException
import io.ktor.serialization.kotlinx.json.json
@@ -144,6 +145,22 @@ class KvittApi(
parameter("measure", measure)
}
// E-36 to E-44 (card purchases, v0.35): the caller's own card rows only (CP-11). E-39 is the bank's browser callback.
suspend fun cardProviders(): ApiResult<CardProvidersResponse> = authed(HttpMethod.Get, "/cards/providers")
suspend fun cardAuth(request: CardAuthRequest): ApiResult<CardAttemptDto> = authed(HttpMethod.Post, "/cards/auth") { jsonBody(request) }
suspend fun cardAttempt(attemptId: String): ApiResult<CardAttemptDto> = authed(HttpMethod.Get, "/cards/auth/${segment(attemptId)}")
suspend fun cardSync(since: Long, limit: Int): ApiResult<CardSyncPage> = authed(HttpMethod.Get, "/cards/sync") {
parameter("since", since)
parameter("limit", limit)
}
suspend fun patchCardConnection(id: String, request: PatchCardConnectionRequest): ApiResult<CardConnectionDto> =
authed(HttpMethod.Patch, "/cards/connections/${segment(id)}") { jsonBody(request) }
suspend fun refreshCardConnection(id: String): ApiResult<CardRefreshResponse> = authed(HttpMethod.Post, "/cards/connections/${segment(id)}/refresh")
suspend fun disconnectCard(id: String, eraseHandled: Boolean): ApiResult<CardConnectionDto> =
authed(HttpMethod.Delete, "/cards/connections/${segment(id)}") { parameter("erase_handled", eraseHandled) }
suspend fun putCardPurchase(id: String, request: CardPurchaseUpdateRequest): ApiResult<CardPurchaseDto> =
authed(HttpMethod.Put, "/cards/purchases/${segment(id)}") { jsonBody(request) }
private suspend inline fun <reified T> authed(
method: HttpMethod,
path: String,
@@ -241,6 +258,9 @@ class KvittApi(
private fun url(t: ApiTarget, path: String): String = t.baseUrl.trimEnd('/') + path
/** An id as one path segment: the server's ids are UUIDs, anything else is percent-encoded. */
private fun segment(id: String): String = id.encodeURLPathPart()
private inline fun <reified B> HttpRequestBuilder.jsonBody(body: B) {
contentType(ContentType.Application.Json)
setBody(body, typeInfo<B>())
@@ -0,0 +1,87 @@
package se.hagstedt.kvitt.data.cards
import se.hagstedt.kvitt.data.api.CardConnectionDto
import se.hagstedt.kvitt.data.api.CardProviderDto
import se.hagstedt.kvitt.data.api.CardPurchaseDto
import se.hagstedt.kvitt.data.db.CardConnectionEntity
import se.hagstedt.kvitt.data.db.CardPurchaseEntity
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.CardPurchaseStatus
// Card wire <-> Room <-> model. Wire enums are lowercase names (§14.2.4); an unknown value degrades gracefully.
fun CardConnectionDto.toEntity(localUpdatedAt: Long) = CardConnectionEntity(
id = id,
providerId = providerId,
status = status,
historyDays = historyDays,
validUntil = validUntil,
lastFetchAt = lastFetchAt,
lastFetchError = lastFetchError,
createdAt = createdAt,
updatedAt = updatedAt,
cardVersion = cardVersion,
localUpdatedAt = localUpdatedAt,
)
/** A pulled or E-44 row: not pending. Tombstones are never stored (the caller deletes the row). */
fun CardPurchaseDto.toEntity(localUpdatedAt: Long) = CardPurchaseEntity(
id = id,
connectionId = connectionId,
status = status,
direction = direction,
purchaseDate = purchaseDate,
bookingDate = bookingDate,
amountOre = amountOre,
originalAmount = originalAmount,
originalCurrency = originalCurrency,
merchant = merchant,
expenseId = expenseId,
ignored = ignored,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
cardVersion = cardVersion,
pending = false,
localUpdatedAt = localUpdatedAt,
)
fun CardProviderDto.toModel() = CardProvider(id = id, name = name, issuer = issuer, imageKey = imageKey, available = available)
fun connectionStatusOf(wire: String): CardConnectionStatus =
CardConnectionStatus.entries.firstOrNull { it.name.lowercase() == wire } ?: CardConnectionStatus.DISCONNECTED
fun purchaseStatusOf(wire: String): CardPurchaseStatus = if (wire == "pending") CardPurchaseStatus.PENDING else CardPurchaseStatus.BOOKED
fun directionOf(wire: String): CardDirection = if (wire == "credit") CardDirection.CREDIT else CardDirection.DEBIT
/** CP-14: null clears; an unknown value reads as [CardFetchError.FAILED] (§14.2.4). */
fun fetchErrorOf(wire: String?): CardFetchError? =
wire?.let { w -> CardFetchError.entries.firstOrNull { it.name.lowercase() == w } ?: CardFetchError.FAILED }
/** [expiresSoon] per CP-6: ACTIVE and `valid_until` within 14 days of [nowMillis]. */
fun CardConnectionEntity.toModel(nowMillis: Long, parse: (String) -> Long?): CardConnection {
val state = connectionStatusOf(status)
val until = validUntil?.let(parse)
return CardConnection(
id = CardConnectionId(id),
providerId = providerId,
status = state,
historyDays = historyDays,
validUntil = validUntil,
lastFetchAt = lastFetchAt,
lastFetchError = fetchErrorOf(lastFetchError),
expiresSoon = state == CardConnectionStatus.ACTIVE && until != null && until - nowMillis <= EXPIRY_NOTICE_MILLIS,
)
}
fun CardConnectionDto.toModel(nowMillis: Long, parse: (String) -> Long?): CardConnection = toEntity(nowMillis).toModel(nowMillis, parse)
/** CP-6: "From 14 days before it, S-23 and S-26 show the expiry notice". */
const val EXPIRY_NOTICE_DAYS = 14
private const val EXPIRY_NOTICE_MILLIS = EXPIRY_NOTICE_DAYS * 24L * 60 * 60 * 1000
@@ -0,0 +1,173 @@
package se.hagstedt.kvitt.data.cards
import androidx.room3.withWriteTransaction
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import se.hagstedt.kvitt.data.api.ApiResult
import se.hagstedt.kvitt.data.api.CardConnectionDto
import se.hagstedt.kvitt.data.api.CardPurchaseDto
import se.hagstedt.kvitt.data.api.CardPurchaseUpdateRequest
import se.hagstedt.kvitt.data.api.CardSyncPage
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.db.CardPurchaseEntity
import se.hagstedt.kvitt.data.db.CardSyncStateEntity
import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.data.util.nowTimestamp
/** The outcome of one card cycle. Card errors never change the household sync's outcome (§14.4.1). */
sealed interface CardCycle {
data object Success : CardCycle
/** E-40 answered 404 `not_found`: a server before schema 4; the card tables stay as they are (empty). */
data object NoSupport : CardCycle
/** [code]: a §14.3.2 code or `network`. */
data class Failed(val code: String) : CardCycle
}
/**
* The phone's card cycle (SPEC §14.2.4 `:data`, §14.2.1, CP-9): push every pending link, unlink and ignore change with
* E-44, then pull E-40 pages until `has_more` is false. It runs after every household sync cycle (so an expense a link
* names is on the server first) and after `CardRepository.refresh`; one cycle at a time ([mutex]). Card content is never
* logged (§8.7, D-163).
*/
class CardSync(private val db: KvittDatabase, private val api: KvittApi, private val clock: Clock) {
private val mutex = Mutex()
suspend fun cycle(): CardCycle = mutex.withLock {
val pushError = push()
if (pushError != null) return@withLock CardCycle.Failed(pushError)
pull()
}
/**
* Drops the card cache (§14.2.4): every connection and every purchase that is not pending, cursor 0. Runs inside the
* caller's transaction for a household epoch change, and here for `cursor_ahead`.
*/
suspend fun dropCache() {
db.cards().deleteSettledPurchases()
db.cards().deleteConnections()
db.cards().upsertSyncState(INITIAL_STATE)
}
// E-44 for each pending row (§14.2.1): 200 overwrites and clears `pending`; `not_found` with field `expense_id`
// (the expense is not on the server yet) stays pending; `not_found` for the purchase deletes it;
// `expense_already_linked` and `validation_failed` take the server's state at the next pull.
/** The error that ends the cycle (network, 5xx, rate limit, unauthorised), or null. */
private suspend fun push(): String? {
for (row in db.cards().pendingPurchases()) {
val expenseId = row.expenseId?.let { id -> id.takeIf { linkable(it) } ?: clearDeadLink(row) }
val sent = db.cards().purchase(row.id) ?: continue
val request = CardPurchaseUpdateRequest(expenseId, sent.ignored)
when (val result = api.putCardPurchase(row.id, request)) {
is ApiResult.Ok -> accept(sent, result.value)
is ApiResult.NetworkError -> return ErrorCodes.NETWORK
ApiResult.NotPaired -> return ErrorCodes.UNAUTHORIZED
is ApiResult.Failure -> when {
result.status == NOT_FOUND && result.field == FIELD_EXPENSE_ID -> Unit
result.status == NOT_FOUND -> db.cards().deletePurchase(row.id)
result.code == ErrorCodes.EXPENSE_ALREADY_LINKED || result.code == ErrorCodes.VALIDATION_FAILED -> takeServerState(sent)
result.status >= SERVER_ERROR || result.code == ErrorCodes.RATE_LIMITED || result.status == UNAUTHORIZED -> return result.code
else -> Unit
}
}
}
return null
}
/** A link may be sent while its expense is on the phone and not deleted; otherwise it is cleared first (CP-9). */
private suspend fun linkable(expenseId: String): Boolean {
val expense = db.expenses().get(expenseId) ?: return false
return expense.deletedAt == null
}
private suspend fun clearDeadLink(row: CardPurchaseEntity): String? {
db.cards().upsertPurchase(row.copy(expenseId = null, localUpdatedAt = clock.nowMillis()))
return null
}
/** 200: the answer replaces the row unless it was changed again during the request (then it stays pending). */
private suspend fun accept(sent: CardPurchaseEntity, dto: CardPurchaseDto) = db.withWriteTransaction {
val now = db.cards().purchase(sent.id) ?: return@withWriteTransaction
if (now.localUpdatedAt == sent.localUpdatedAt) {
db.cards().upsertPurchase(dto.toEntity(clock.nowMillis()))
} else {
db.cards().upsertPurchase(now.copy(cardVersion = dto.cardVersion))
}
}
/**
* The server refused the change for a reason that will not go away (`expense_already_linked`, `validation_failed`):
* the row stops being pending and is pulled again. Its version is reset and the cursor moved before it, because the
* server's row may not have changed and would otherwise never be sent again (proposed decision, K2 report).
*/
private suspend fun takeServerState(row: CardPurchaseEntity) = db.withWriteTransaction {
db.cards().upsertPurchase(row.copy(pending = false, cardVersion = 0, localUpdatedAt = clock.nowMillis()))
val state = state()
val before = (row.cardVersion - 1).coerceAtLeast(0)
if (state.cursor > before) db.cards().upsertSyncState(state.copy(cursor = before))
}
// E-40 pull (§14.2.4): like §14.4.2, a pending local row is skipped and a tombstone deletes the row.
private suspend fun pull(): CardCycle {
var restarted = false
while (true) {
val state = state()
when (val result = api.cardSync(state.cursor, PULL_LIMIT)) {
is ApiResult.Ok -> {
val page = result.value
db.withWriteTransaction {
apply(page)
db.cards().upsertSyncState(state().copy(cursor = page.nextSince, lastSuccessAt = clock.nowTimestamp()))
}
if (!page.hasMore) return CardCycle.Success
}
is ApiResult.NetworkError -> return CardCycle.Failed(ErrorCodes.NETWORK)
ApiResult.NotPaired -> return CardCycle.Failed(ErrorCodes.UNAUTHORIZED)
is ApiResult.Failure -> when {
result.status == NOT_FOUND -> return CardCycle.NoSupport
result.code == ErrorCodes.CURSOR_AHEAD && !restarted -> {
restarted = true
db.withWriteTransaction { dropCache() }
}
else -> return CardCycle.Failed(result.code)
}
}
}
}
private suspend fun apply(page: CardSyncPage) {
val now = clock.nowMillis()
for (dto in page.connections) writeConnectionIfNewer(dto, now)
for (dto in page.purchases) {
val local = db.cards().purchase(dto.id)
when {
dto.deletedAt != null -> if (local != null) db.cards().deletePurchase(dto.id)
local != null && local.pending -> Unit
local == null || local.cardVersion < dto.cardVersion -> db.cards().upsertPurchase(dto.toEntity(now))
}
}
}
/** A connection from any card answer (E-38, E-40 to E-43), written when it is newer than the phone's. */
suspend fun writeConnectionIfNewer(dto: CardConnectionDto, now: Long = clock.nowMillis()) {
val local = db.cards().connection(dto.id)
if (local == null || local.cardVersion <= dto.cardVersion) db.cards().upsertConnection(dto.toEntity(now))
}
private suspend fun state(): CardSyncStateEntity = db.cards().syncState() ?: INITIAL_STATE
companion object {
/** Pages of 500, as the household pull (§14.4.9; E-40's default). */
const val PULL_LIMIT = 500
val INITIAL_STATE = CardSyncStateEntity(id = 1, cursor = 0, lastSuccessAt = null)
private const val NOT_FOUND = 404
private const val UNAUTHORIZED = 401
private const val SERVER_ERROR = 500
private const val FIELD_EXPENSE_ID = "expense_id"
}
}
@@ -0,0 +1,147 @@
package se.hagstedt.kvitt.data.cards
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.data.db.CardConnectionEntity
import se.hagstedt.kvitt.data.db.CardPurchaseEntity
import se.hagstedt.kvitt.data.repo.ExpenseRecord
import se.hagstedt.kvitt.data.repo.Snapshot
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.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.CardsState
import se.hagstedt.kvitt.model.ExpenseMatch
import se.hagstedt.kvitt.model.LinkedExpense
import kotlin.math.abs
/**
* The card `*State`s of §14.2.4 from one consistent read of Room (the card tables and the household [snapshot]), with
* `:domain`'s [matchCardPurchase] for the suggested matches (CP-10). Pure; the repository supplies the clock.
*/
internal class CardView(
private val snapshot: Snapshot,
private val connections: List<CardConnectionEntity>,
purchases: List<CardPurchaseEntity>,
private val catalogue: List<CardProvider>,
private val nowMillis: Long,
private val parseTimestamp: (String) -> Long?,
) {
private val live = purchases.filter { it.deletedAt == null }
private val newestFirst = live.sortedWith(compareByDescending<CardPurchaseEntity> { it.purchaseDate }.thenByDescending { it.createdAt }.thenByDescending { it.id })
private val linkedBy: Map<String, List<String>> = live.filter { it.expenseId != null }.groupBy({ it.expenseId.orEmpty() }, { it.id })
private val expenses: List<ExpenseRecord> = snapshot.liveExpenses
/** The connection S-23 shows: ACTIVE or EXPIRED (one per provider, CP-1); null is the empty state. */
val connection: CardConnectionEntity? = connections.filter { connectionStatusOf(it.status) != CardConnectionStatus.DISCONNECTED }.maxByOrNull { it.updatedAt }
val hasConnection: Boolean get() = connection != null
/** The provider of [connection], else the catalogue's first entry; an unknown id gets a nameless entry with its id as image key. */
val provider: CardProvider? = connection?.let { c -> catalogue.firstOrNull { it.id == c.providerId } ?: CardProvider(c.providerId, "", "", c.providerId, true) }
?: catalogue.firstOrNull()
fun state(filter: CardFilter, fetching: Boolean): CardsState {
val current = connection ?: return CardsState(provider, null, filter, CardFilter.entries.associateWith { 0 }, emptyList(), fetching)
val rows = newestFirst.filter { it.inFilter(filter) }.map { row(it, withMatches = filter == CardFilter.TO_HANDLE) }
val days = rows.groupBy { it.purchase.date }.map { (date, inDay) -> CardDay(date, inDay) }
return CardsState(
provider = provider,
connection = current.toModel(nowMillis, parseTimestamp),
filter = filter,
counts = CardFilter.entries.associateWith { f -> live.count { it.inFilter(f) } },
days = days,
fetching = fetching,
)
}
fun detail(id: CardPurchaseId): CardPurchaseDetail? {
val p = live.firstOrNull { it.id == id.value } ?: return null
return CardPurchaseDetail(row(p, withMatches = p.toHandle()), if (p.toHandle()) others(p) else emptyList())
}
/** S-27: debit, neither linked nor ignored, newest first (foreign currency included). */
fun toHandle(): List<CardPurchase> = newestFirst.filter { it.toHandle() }.map { model(it) }
fun forExpense(expenseId: String): CardPurchase? = live.firstOrNull { it.expenseId == expenseId }?.let(::model)
fun purchase(id: String): CardPurchaseEntity? = live.firstOrNull { it.id == id }
private fun row(p: CardPurchaseEntity, withMatches: Boolean) = CardPurchaseRow(model(p), if (withMatches) matches(p) else emptyList())
fun model(p: CardPurchaseEntity): CardPurchase = CardPurchase(
id = CardPurchaseId(p.id),
connectionId = CardConnectionId(p.connectionId),
status = purchaseStatusOf(p.status),
direction = directionOf(p.direction),
date = p.purchaseDate,
bookingDate = p.bookingDate,
amountOre = p.amountOre,
originalAmount = p.originalAmount,
originalCurrency = p.originalCurrency,
merchant = p.merchant,
linked = p.expenseId?.let { linkedExpense(it) },
ignored = p.ignored,
pendingLocal = p.pending,
)
private fun linkedExpense(id: String): LinkedExpense? {
val e = snapshot.expense(id)?.takeIf { !it.deleted } ?: return null
return LinkedExpense(e.id, e.row.description, snapshot.account(e.row.accountId)?.name.orEmpty())
}
/** CP-10 through `:domain`: same amount, ±3 days, not linked from another purchase of the viewer, at most 3. */
private fun matches(p: CardPurchaseEntity): List<ExpenseMatch> {
val date = parseDate(p.purchaseDate) ?: return emptyList()
val candidates = expenses.mapNotNull { e -> parseDate(e.row.expenseDate)?.let { d -> MatchableExpense(e.row.id, e.row.amountOre, d, e.row.createdAt, linkedByOther(e.row.id, p.id)) } }
val ids = matchCardPurchase(MatchablePurchase(p.amountOre, date, p.direction), candidates, CARD_MATCH_WINDOW_DAYS, CARD_MATCH_MAX)
return ids.mapNotNull { id -> snapshot.expense(id)?.let(::expenseMatch) }
}
/** "Välj en annan utgift" (CP-10): not linked from another purchase of the viewer, within 14 days, any amount, newest first. */
private fun others(p: CardPurchaseEntity): List<ExpenseMatch> {
val day = parseDate(p.purchaseDate)?.toEpochDays() ?: return emptyList()
return expenses
.filter { e -> !linkedByOther(e.row.id, p.id) && parseDate(e.row.expenseDate)?.let { abs(it.toEpochDays() - day) <= OTHERS_WINDOW_DAYS } == true }
.sortedWith(compareByDescending<ExpenseRecord> { it.row.expenseDate }.thenByDescending { it.row.createdAt }.thenBy { it.row.id })
.mapNotNull(::expenseMatch)
}
private fun expenseMatch(e: ExpenseRecord): ExpenseMatch? {
val payer = snapshot.user(e.row.payerUserId) ?: return null
return ExpenseMatch(e.id, e.row.description, snapshot.account(e.row.accountId)?.name.orEmpty(), payer, e.row.expenseDate, e.row.amountOre)
}
private fun linkedByOther(expenseId: String, purchaseId: String): Boolean = linkedBy[expenseId].orEmpty().any { it != purchaseId }
companion object {
/** CP-10: "Välj en annan utgift" lists expenses dated within 14 days of the purchase. */
const val OTHERS_WINDOW_DAYS = 14
}
}
/** CP-8 and §13.3 S-23: "Att hantera" is a debit purchase neither linked nor ignored; a credit shows only under "Alla". */
internal fun CardPurchaseEntity.toHandle(): Boolean = directionOf(direction) == CardDirection.DEBIT && expenseId == null && !ignored
internal fun CardPurchaseEntity.inFilter(filter: CardFilter): Boolean = when (filter) {
CardFilter.TO_HANDLE -> toHandle()
CardFilter.LINKED -> expenseId != null
CardFilter.IGNORED -> ignored
CardFilter.ALL -> true
}
private fun parseDate(iso: String): LocalDate? = try {
LocalDate.parse(iso)
} catch (e: IllegalArgumentException) {
null
}
@@ -250,5 +250,60 @@ interface RejectionDao {
suspend fun dismiss(mutationId: String)
}
/**
* The card tables (v2, D-173): the signed-in user's own connections and purchases and the card cursor (§14.2.1, §14.2.2).
* Tombstones are never stored (a pulled tombstone deletes the row), so every purchase row is live.
*/
@Dao
interface CardDao {
@Query("SELECT * FROM card_connections ORDER BY created_at, id")
fun observeConnections(): Flow<List<CardConnectionEntity>>
@Query("SELECT * FROM card_connections ORDER BY created_at, id")
suspend fun connections(): List<CardConnectionEntity>
@Query("SELECT * FROM card_connections WHERE id = :id")
suspend fun connection(id: String): CardConnectionEntity?
@Upsert
suspend fun upsertConnection(connection: CardConnectionEntity)
@Query("SELECT * FROM card_purchases WHERE deleted_at IS NULL")
fun observePurchases(): Flow<List<CardPurchaseEntity>>
@Query("SELECT * FROM card_purchases WHERE deleted_at IS NULL")
suspend fun purchases(): List<CardPurchaseEntity>
@Query("SELECT * FROM card_purchases WHERE id = :id")
suspend fun purchase(id: String): CardPurchaseEntity?
@Upsert
suspend fun upsertPurchase(purchase: CardPurchaseEntity)
@Query("DELETE FROM card_purchases WHERE id = :id")
suspend fun deletePurchase(id: String)
/** The E-44 queue (CP-9): oldest local change first. */
@Query("SELECT * FROM card_purchases WHERE pending = 1 ORDER BY local_updated_at, id")
suspend fun pendingPurchases(): List<CardPurchaseEntity>
/** CP-9: a local expense delete clears the local link to it; a pending change stays pending. */
@Query("UPDATE card_purchases SET expense_id = NULL, local_updated_at = :localUpdatedAt WHERE expense_id = :expenseId")
suspend fun clearLinksTo(expenseId: String, localUpdatedAt: Long): Int
/** §14.2.4: `cursor_ahead` or a household epoch change drops the card cache; pending purchases are kept. */
@Query("DELETE FROM card_purchases WHERE pending = 0")
suspend fun deleteSettledPurchases()
@Query("DELETE FROM card_connections")
suspend fun deleteConnections()
@Query("SELECT * FROM card_sync_state WHERE id = 1")
suspend fun syncState(): CardSyncStateEntity?
@Upsert
suspend fun upsertSyncState(state: CardSyncStateEntity)
}
/** One row of [ExpenseDao.observeCategoryUse]: how many non-deleted expenses of [accountId] use [categoryId]. */
data class CategoryUse(val accountId: String, val categoryId: String, val uses: Int)
@@ -150,6 +150,51 @@ data class SettlementAllocationEntity(
@ColumnInfo(name = "amount_ore") val amountOre: Long,
)
/**
* The signed-in user's card connections (v2, D-173, §14.2.1): the server's columns without `user_id`, `session_id`,
* `account_uid`, `last_user_fetch_at` and `background_fetches`. `card_version` is the row's E-40 version.
*/
@Entity(tableName = "card_connections")
data class CardConnectionEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "provider_id") val providerId: String,
@ColumnInfo(name = "status") val status: String,
@ColumnInfo(name = "history_days") val historyDays: Int,
@ColumnInfo(name = "valid_until") val validUntil: String?,
@ColumnInfo(name = "last_fetch_at") val lastFetchAt: String?,
@ColumnInfo(name = "last_fetch_error") val lastFetchError: String?,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "card_version") val cardVersion: Long,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
/**
* The signed-in user's card purchases (v2, D-173, §14.2.1), without `provider_ref`. Local `pending`: a link, unlink or
* ignore change not yet accepted by E-44 (CP-9); a card pull skips a pending row.
*/
@Entity(tableName = "card_purchases", indices = [Index("purchase_date"), Index("expense_id"), Index("pending")])
data class CardPurchaseEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@ColumnInfo(name = "connection_id") val connectionId: String,
@ColumnInfo(name = "status") val status: String,
@ColumnInfo(name = "direction") val direction: String,
@ColumnInfo(name = "purchase_date") val purchaseDate: String,
@ColumnInfo(name = "booking_date") val bookingDate: String?,
@ColumnInfo(name = "amount_ore") val amountOre: Long?,
@ColumnInfo(name = "original_amount") val originalAmount: String?,
@ColumnInfo(name = "original_currency") val originalCurrency: String?,
@ColumnInfo(name = "merchant") val merchant: String,
@ColumnInfo(name = "expense_id") val expenseId: String?,
@ColumnInfo(name = "ignored") val ignored: Boolean,
@ColumnInfo(name = "created_at") val createdAt: String,
@ColumnInfo(name = "updated_at") val updatedAt: String,
@ColumnInfo(name = "deleted_at") val deletedAt: String?,
@ColumnInfo(name = "card_version") val cardVersion: Long,
@ColumnInfo(name = "pending") val pending: Boolean,
@ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long,
)
@Entity(tableName = "audit_log", indices = [Index("entity_type", "entity_id")])
data class AuditEntryEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String,
@@ -177,6 +222,14 @@ data class SyncStateEntity(
@ColumnInfo(name = "clock_skew") val clockSkew: Boolean,
)
/** The card cycle's cursor (v2, D-173, §14.2.2): the last applied `next_since` of E-40, 0 initially; one row, `id` 1. */
@Entity(tableName = "card_sync_state")
data class CardSyncStateEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "cursor") val cursor: Long,
@ColumnInfo(name = "last_success_at") val lastSuccessAt: String?,
)
@Entity(tableName = "sync_rejections")
data class SyncRejectionEntity(
@PrimaryKey @ColumnInfo(name = "mutation_id") val mutationId: String,
@@ -5,12 +5,15 @@ import androidx.room3.Database
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.execSQL
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
/**
* The phone's database (SPEC §14.2): file `kvitt.db`, version 1, schema exported to `android/data/schemas/`.
* The phone's database (SPEC §14.2): file `kvitt.db`, version 2 (the card tables, D-173), schemas exported to
* `android/data/schemas/`.
* Every later version adds a migration to [KvittMigrations.ALL] and its test in `MigrationTest` (§14.2.3).
*/
@Database(
@@ -25,8 +28,11 @@ import kotlinx.coroutines.Dispatchers
ExpenseSplitEntity::class,
SettlementEntity::class,
SettlementAllocationEntity::class,
CardConnectionEntity::class,
CardPurchaseEntity::class,
AuditEntryEntity::class,
SyncStateEntity::class,
CardSyncStateEntity::class,
SyncRejectionEntity::class,
],
version = KvittDatabase.VERSION,
@@ -42,10 +48,11 @@ abstract class KvittDatabase : RoomDatabase() {
abstract fun audit(): AuditDao
abstract fun syncState(): SyncStateDao
abstract fun rejections(): RejectionDao
abstract fun cards(): CardDao
companion object {
const val FILE_NAME = "kvitt.db"
const val VERSION = 1
const val VERSION = 2
/**
* The app's database. Production uses the bundled SQLite driver (Room 3 with sqlite-bundled);
@@ -60,7 +67,35 @@ abstract class KvittDatabase : RoomDatabase() {
}
}
/** Every Room migration, in version order. Version 1 has none (§14.2.3). */
/** Every Room migration, in version order (§14.2.3). */
object KvittMigrations {
val ALL: List<Migration> = emptyList()
/**
* Version 2 (D-173, K2): the card tables `card_connections`, `card_purchases` and `card_sync_state` (with its row
* `id` 1, `cursor` 0); no existing table changes. The statements are Room's own `createSql` of the exported `2.json`.
*/
val MIGRATION_1_2: Migration = object : Migration(1, 2) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `card_connections` (`id` TEXT NOT NULL, `provider_id` TEXT NOT NULL, `status` TEXT NOT NULL, " +
"`history_days` INTEGER NOT NULL, `valid_until` TEXT, `last_fetch_at` TEXT, `last_fetch_error` TEXT, `created_at` TEXT NOT NULL, " +
"`updated_at` TEXT NOT NULL, `card_version` INTEGER NOT NULL, `local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `card_purchases` (`id` TEXT NOT NULL, `connection_id` TEXT NOT NULL, `status` TEXT NOT NULL, " +
"`direction` TEXT NOT NULL, `purchase_date` TEXT NOT NULL, `booking_date` TEXT, `amount_ore` INTEGER, `original_amount` TEXT, " +
"`original_currency` TEXT, `merchant` TEXT NOT NULL, `expense_id` TEXT, `ignored` INTEGER NOT NULL, `created_at` TEXT NOT NULL, " +
"`updated_at` TEXT NOT NULL, `deleted_at` TEXT, `card_version` INTEGER NOT NULL, `pending` INTEGER NOT NULL, " +
"`local_updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_purchase_date` ON `card_purchases` (`purchase_date`)")
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_expense_id` ON `card_purchases` (`expense_id`)")
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_card_purchases_pending` ON `card_purchases` (`pending`)")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `card_sync_state` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `last_success_at` TEXT, PRIMARY KEY(`id`))",
)
connection.execSQL("INSERT OR IGNORE INTO `card_sync_state` (`id`, `cursor`, `last_success_at`) VALUES (1, 0, NULL)")
}
}
val ALL: List<Migration> = listOf(MIGRATION_1_2)
}
@@ -41,6 +41,7 @@ import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.repo.AboutRepositoryImpl
import se.hagstedt.kvitt.data.repo.AccountRepositoryImpl
import se.hagstedt.kvitt.data.repo.AnalyticsRepositoryImpl
import se.hagstedt.kvitt.data.repo.CardRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
@@ -71,6 +72,7 @@ import se.hagstedt.kvitt.data.util.UuidV7Generator
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.AnalyticsRepository
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.DeviceRepository
@@ -293,6 +295,19 @@ object DataModule {
fun sync(local: LocalData, db: KvittDatabase, summaries: SyncSummaries, engine: SyncEngine, work: SyncWork): SyncRepositoryImpl =
SyncRepositoryImpl(local, db, summaries, engine, work)
@Provides @Singleton
fun cards(
db: KvittDatabase,
local: LocalData,
api: KvittApi,
gate: OnlineGate,
engine: SyncEngine,
prefs: PhonePrefs,
clock: Clock,
triggers: SyncTriggers,
@DataScope scope: CoroutineScope,
): CardRepositoryImpl = CardRepositoryImpl(db, local, api, gate, engine.cards, prefs, clock, triggers, scope)
@Provides @Singleton
fun about(credentials: CredentialStore, session: SessionManager, api: KvittApi, gate: OnlineGate, build: AppBuildInfo): AboutRepositoryImpl =
AboutRepositoryImpl(credentials, session.serverVersion, api, gate, build.versionName, build.versionCode, build.releaseServerUrl)
@@ -318,6 +333,7 @@ abstract class DataBindingsModule {
@Binds abstract fun update(impl: UpdateRepositoryImpl): UpdateRepository
@Binds abstract fun about(impl: AboutRepositoryImpl): AboutRepository
@Binds abstract fun connectivity(impl: ConnectivityRepositoryImpl): ConnectivityRepository
@Binds abstract fun cards(impl: CardRepositoryImpl): CardRepository
}
private fun Flow<PairingRecord>.stateInBaseUrl(scope: CoroutineScope): StateFlow<String?> =
@@ -0,0 +1,290 @@
package se.hagstedt.kvitt.data.repo
import androidx.room3.withWriteTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import se.hagstedt.kvitt.data.api.ApiResult
import se.hagstedt.kvitt.data.api.CardAuthRequest
import se.hagstedt.kvitt.data.api.CardAttemptDto
import se.hagstedt.kvitt.data.api.CardProviderDto
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.PatchCardConnectionRequest
import se.hagstedt.kvitt.data.cards.CardSync
import se.hagstedt.kvitt.data.cards.CardView
import se.hagstedt.kvitt.data.cards.directionOf
import se.hagstedt.kvitt.data.cards.toModel
import se.hagstedt.kvitt.data.connectivity.OnlineGate
import se.hagstedt.kvitt.data.db.CardConnectionEntity
import se.hagstedt.kvitt.data.db.CardPurchaseEntity
import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.sync.LocalWriteListener
import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.data.util.Timestamps
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnectStart
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardConnectionStatus
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.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.OnlineResult
import se.hagstedt.kvitt.model.SplitMode
import java.time.format.DateTimeParseException
/**
* Card purchases (S-23 to S-27, FR-22, §9.12) on the Room card tables (§14.2.1) and the household snapshot. Reads work
* offline; link, unlink and ignore are local first and go out with the next card cycle after the household push (CP-9,
* through [writes], which asks for a sync); connecting, renewing, refreshing, the history setting and disconnecting
* are online only (§9.9, through [gate]). Card content is never logged (§8.7).
*/
class CardRepositoryImpl(
private val db: KvittDatabase,
private val local: LocalData,
private val api: KvittApi,
private val gate: OnlineGate,
private val sync: CardSync,
private val prefs: PhonePrefs,
private val clock: Clock,
private val writes: LocalWriteListener,
private val scope: CoroutineScope,
) : CardRepository {
private val fetching = MutableStateFlow(false)
private var follow: Job? = null
private val catalogue: Flow<List<CardProvider>> = prefs.cardProviders.map { json -> json?.let(::decodeProviders).orEmpty() }
private val view: Flow<CardView> = combine(local.snapshot, db.cards().observeConnections(), db.cards().observePurchases(), catalogue) { s, c, p, providers ->
CardView(s, c, p, providers, clock.nowMillis(), ::parseTimestamp)
}
override val hasConnection: Flow<Boolean> = db.cards().observeConnections()
.map { rows -> rows.any { it.status == ACTIVE || it.status == EXPIRED } }
.distinctUntilChanged()
override fun cards(filter: CardFilter): Flow<CardsState> = combine(view, fetching) { v, running -> v.state(filter, running) }.distinctUntilChanged()
override fun purchase(id: CardPurchaseId): Flow<CardPurchaseDetail?> = view.map { it.detail(id) }.distinctUntilChanged()
override fun toHandle(): Flow<List<CardPurchase>> = view.map { it.toHandle() }.distinctUntilChanged()
override fun purchaseForExpense(id: ExpenseId): Flow<CardPurchase?> = view.map { it.forExpense(id.value) }.distinctUntilChanged()
/** E-36; a successful answer is kept as the catalogue S-23 names its card from. */
override suspend fun providers(): OnlineResult<List<CardProvider>> = gate.run({ api.cardProviders() }) { response ->
prefs.saveCardProviders(KvittJson.encodeToString(PROVIDERS, response.providers))
response.providers.map { it.toModel() }
}
/** E-37 for a new connection or a renewal (CP-2); `client` is `android`. */
override suspend fun startConnect(providerId: String, historyDays: Int): OnlineResult<CardConnectStart> {
val result = gate.run({ api.cardAuth(CardAuthRequest(providerId, historyDays, CLIENT)) }) { it }
return when (result) {
is OnlineResult.Ok -> result.value.authUrl?.takeIf { it.isNotBlank() }?.let { OnlineResult.Ok(CardConnectStart(result.value.id, it, result.value.expiresAt)) }
?: OnlineResult.Failed(ErrorCodes.INTERNAL, null)
OnlineResult.Offline -> OnlineResult.Offline
is OnlineResult.Failed -> result
}
}
/** E-38. Once connected, the connection is written at once and the first fetch is followed with card pulls (CP-2). */
override suspend fun attempt(attemptId: String): OnlineResult<CardAttemptState> = gate.run({ api.cardAttempt(attemptId) }) { dto -> attemptState(dto) }
private suspend fun attemptState(dto: CardAttemptDto): CardAttemptState = when (dto.status) {
STATUS_PENDING -> CardAttemptState.Pending
STATUS_CONNECTED -> {
val connection = dto.connection
if (connection == null) {
CardAttemptState.Pending
} else {
sync.writeConnectionIfNewer(connection)
followFetch(connection.id)
ensureCatalogue()
CardAttemptState.Connected(connection.toModel(clock.nowMillis(), ::parseTimestamp))
}
}
STATUS_EXPIRED -> CardAttemptState.Expired
else -> CardAttemptState.Failed(dto.failure ?: STATUS_FAILED)
}
/**
* "Uppdatera" and pull to refresh (CP-5): E-42 for the connection, then a card pull. When the server is still
* fetching (`connection.fetching`), card pulls follow until the fetch has finished (at most [FOLLOW_ROUNDS]).
*/
override suspend fun refresh(): OnlineResult<Unit> {
val connection = current() ?: return OnlineResult.Failed(ErrorCodes.NOT_FOUND, null)
if (fetching.value) return OnlineResult.Ok(Unit)
fetching.value = true
try {
val result = gate.run({ api.refreshCardConnection(connection.id) }) { response ->
sync.writeConnectionIfNewer(response.connection)
response
}
if (result is OnlineResult.Ok) {
sync.cycle()
if (result.value.connection.fetching) followFetch(connection.id)
ensureCatalogue()
}
return result.unit()
} finally {
fetching.value = false
}
}
/** E-41: a shorter history only (CP-3; a longer one is a renewal). */
override suspend fun setHistoryDays(id: CardConnectionId, days: Int): OnlineResult<Unit> =
gate.run({ api.patchCardConnection(id.value, PatchCardConnectionRequest(days)) }) { sync.writeConnectionIfNewer(it) }
/** E-43, then a card pull, which brings the deleted purchases (CP-12). */
override suspend fun disconnect(id: CardConnectionId, eraseHandled: Boolean): OnlineResult<Unit> {
val result = gate.run({ api.disconnectCard(id.value, eraseHandled) }) { sync.writeConnectionIfNewer(it) }
if (result is OnlineResult.Ok) sync.cycle()
return result
}
/**
* CP-9: a new expense from the purchase: its amount (null for a foreign currency), the merchant text trimmed to 100
* code points, its date, the viewer as payer, [accountId] if still active else the AC-8.6 default, that account's
* AC-8.6 category (the editor then runs the §9.11 suggestion, CS-1), split `default`, no notes.
*/
override suspend fun prefill(id: CardPurchaseId, accountId: AccountId?): ExpenseDraft? {
val purchase = db.cards().purchase(id.value)?.takeIf { it.deletedAt == null } ?: return null
if (directionOf(purchase.direction) == CardDirection.CREDIT) return null
val s = local.snapshot.first()
val me = s.meId ?: return null
val defaults = prefs.editorDefaults.first()
val active = s.accounts.filter { !it.archived }
val account = accountId?.takeIf { a -> active.any { it.id == a } }
?: defaults.lastAccountId?.let { last -> active.firstOrNull { it.id.value == last }?.id }
?: active.firstOrNull()?.id
val categories = s.categories.filter { !it.archived }
val category = account?.let { a -> defaults.lastCategoryFor[a.value]?.let { last -> categories.firstOrNull { it.id.value == last }?.id } }
?: categories.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }?.id
return ExpenseDraft(
id = null,
accountId = account,
categoryId = category,
amountOre = purchase.amountOre,
description = purchase.merchant.trim().takeCodePoints(DESCRIPTION_MAX),
notes = "",
date = purchase.purchaseDate,
payer = me,
mode = SplitMode.DEFAULT,
ratioBp = null,
exactOre = null,
fullOn = null,
)
}
override suspend fun link(id: CardPurchaseId, expenseId: ExpenseId) = change(id) { it.copy(expenseId = expenseId.value, 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) }
/** CP-9: written to the local copy at once (offline too), pending until E-44 accepts it; a credit takes no change (CP-8). */
private suspend fun change(id: CardPurchaseId, edit: (CardPurchaseEntity) -> CardPurchaseEntity) {
val changed = db.withWriteTransaction {
val row = db.cards().purchase(id.value)?.takeIf { it.deletedAt == null && directionOf(it.direction) == CardDirection.DEBIT } ?: return@withWriteTransaction false
val next = edit(row)
if (next.expenseId == row.expenseId && next.ignored == row.ignored) return@withWriteTransaction false
db.cards().upsertPurchase(next.copy(pending = true, localUpdatedAt = clock.nowMillis()))
true
}
if (changed) writes.onLocalWrite()
}
/** The connection "Uppdatera" asks for: ACTIVE (an EXPIRED one is renewed instead, CP-6). */
private suspend fun current(): CardConnectionEntity? = db.cards().connections().filter { it.status == ACTIVE }.maxByOrNull { it.updatedAt }
/**
* Card pulls every [FOLLOW_DELAY_MILLIS] while the server fetches (the first fetch after BankID, or E-42 answering
* `fetching`), until the connection's `last_fetch_at` or `last_fetch_error` changes, at most [FOLLOW_ROUNDS] times.
* One follower at a time.
*/
private fun followFetch(connectionId: String) {
follow?.cancel()
follow = scope.launch {
val before = db.cards().connection(connectionId)
repeat(FOLLOW_ROUNDS) {
delay(FOLLOW_DELAY_MILLIS)
sync.cycle()
val now = db.cards().connection(connectionId) ?: return@launch
if (now.lastFetchAt != before?.lastFetchAt || now.lastFetchError != before?.lastFetchError) return@launch
}
}
}
/** Fetches the catalogue once when the connected card is not in it yet (it is online only, S-24). */
private suspend fun ensureCatalogue() {
val known = catalogue.first()
val missing = db.cards().connections().any { c -> c.status != DISCONNECTED && known.none { it.id == c.providerId } }
if (missing || known.isEmpty()) {
val result = api.cardProviders()
if (result is ApiResult.Ok) prefs.saveCardProviders(KvittJson.encodeToString(PROVIDERS, result.value.providers))
}
}
private fun decodeProviders(json: String): List<CardProvider> = try {
KvittJson.decodeFromString(PROVIDERS, json).map { it.toModel() }
} catch (e: SerializationException) {
emptyList()
} catch (e: IllegalArgumentException) {
emptyList()
}
private fun parseTimestamp(text: String): Long? = try {
Timestamps.parse(text)
} catch (e: DateTimeParseException) {
null
}
companion object {
private val PROVIDERS = ListSerializer(CardProviderDto.serializer())
private const val CLIENT = "android"
private val ACTIVE = CardConnectionStatus.ACTIVE.name.lowercase()
private val EXPIRED = CardConnectionStatus.EXPIRED.name.lowercase()
private val DISCONNECTED = CardConnectionStatus.DISCONNECTED.name.lowercase()
private const val STATUS_PENDING = "pending"
private const val STATUS_CONNECTED = "connected"
private const val STATUS_EXPIRED = "expired"
private const val STATUS_FAILED = "failed"
/** §9.6: a description is at most 100 code points. */
private const val DESCRIPTION_MAX = 100
const val FOLLOW_DELAY_MILLIS = 3_000L
const val FOLLOW_ROUNDS = 40
}
}
private fun <T> OnlineResult<T>.unit(): OnlineResult<Unit> = when (this) {
is OnlineResult.Ok -> OnlineResult.Ok(Unit)
OnlineResult.Offline -> OnlineResult.Offline
is OnlineResult.Failed -> this
}
/** The first [max] code points (a surrogate pair is one), §9.6. */
internal fun String.takeCodePoints(max: Int): String {
if (codePointCount(0, length) <= max) return this
return substring(0, offsetByCodePoints(0, max))
}
@@ -152,12 +152,15 @@ class ExpenseRepositoryImpl(
return SaveResult.Saved(ExpenseId(id))
}
/** A local delete keeps the row as a tombstone: `deleted_at` = `updated_at` = now, pending (§14.2.1). */
/** A local delete keeps the row as a tombstone: `deleted_at` = `updated_at` = now, pending (§14.2.1), and unlinks its card purchase. */
override suspend fun delete(id: ExpenseId) {
val now = clock.nowTimestamp()
val changed = db.withWriteTransaction {
val row = db.expenses().get(id.value)?.takeIf { it.deletedAt == null } ?: return@withWriteTransaction false
db.expenses().upsert(row.copy(deletedAt = now, updatedAt = now, pending = true, pendingMutationId = ids.newId(), localUpdatedAt = clock.nowMillis()))
// CP-9: the phone clears a link to the expense at its own delete; the server does the same when the delete
// arrives (§14.4.4 step 7), so the purchase is "Att hantera" again at once, offline too.
db.cards().clearLinksTo(id.value, clock.nowMillis())
true
}
if (changed) writes.onLocalWrite()
@@ -15,7 +15,8 @@ data class EditorDefaults(val lastAccountId: String?, val lastCategoryFor: Map<S
/**
* Per-phone preferences in DataStore `prefs.preferences_pb` (§14.2.2): last used account, last used category per
* account, whether the "install unknown apps" explanation was shown, and the app version that got 426.
* account, whether the "install unknown apps" explanation was shown, the app version that got 426, and the last card
* catalogue (E-36).
*/
class PhonePrefs(private val store: DataStore<Preferences>) {
val editorDefaults: Flow<EditorDefaults> = store.data.map { prefs ->
@@ -47,6 +48,16 @@ class PhonePrefs(private val store: DataStore<Preferences>) {
store.edit { it[PROTOCOL_BLOCKED_AT] = versionCode }
}
/**
* The last card catalogue E-36 answered (CP-1), as its JSON; null until the phone has asked once. S-23 names the
* connected card and draws its artwork from it while offline (the catalogue itself is online only, S-24).
*/
val cardProviders: Flow<String?> = store.data.map { it[CARD_PROVIDERS] }
suspend fun saveCardProviders(json: String) {
store.edit { it[CARD_PROVIDERS] = json }
}
suspend fun clear() {
store.edit { it.clear() }
}
@@ -58,5 +69,6 @@ class PhonePrefs(private val store: DataStore<Preferences>) {
private const val CATEGORY_PREFIX = "last_category_"
private val INSTALL_EXPLAINED = booleanPreferencesKey("install_unknown_apps_explained")
private val PROTOCOL_BLOCKED_AT = intPreferencesKey("protocol_blocked_version_code")
private val CARD_PROVIDERS = stringPreferencesKey("card_providers")
}
}
@@ -45,6 +45,8 @@ class LocalRows(private val db: KvittDatabase, private val clock: Clock) {
suspend fun deleteExpense(id: String) {
db.expenses().deleteSplits(id)
db.expenses().delete(id)
// CP-9: a card purchase linked to an expense that is gone from the phone is "Att hantera" again.
db.cards().clearLinksTo(id, clock.nowMillis())
}
suspend fun deleteSettlement(id: String) {
@@ -20,6 +20,7 @@ import se.hagstedt.kvitt.data.api.PushRequest
import se.hagstedt.kvitt.data.api.PushResultDto
import se.hagstedt.kvitt.data.api.SettlementDto
import se.hagstedt.kvitt.data.api.SettlementPushEntity
import se.hagstedt.kvitt.data.cards.CardSync
import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.db.SyncRejectionEntity
import se.hagstedt.kvitt.data.db.SyncStateEntity
@@ -42,7 +43,8 @@ sealed interface SyncOutcome {
/**
* The phone side of sync protocol v1 (SPEC §14.4). One cycle at a time per process ([mutex]); a cycle pushes every
* pending expense and settlement in batches of [PUSH_BATCH], then pulls pages of [PULL_LIMIT] until `has_more` is false.
* pending expense and settlement in batches of [PUSH_BATCH], then pulls pages of [PULL_LIMIT] until `has_more` is false,
* then runs the card cycle ([cards], §14.2.4).
* Epoch changes (§14.4.8) restart the cycle after marking everything pending.
*/
class SyncEngine(
@@ -54,6 +56,9 @@ class SyncEngine(
) {
private val mutex = Mutex()
private val rows = LocalRows(db, clock)
/** The card cycle (§14.2.4): run after each successful household cycle; `CardRepositoryImpl` also runs it. */
val cards = CardSync(db, api, clock)
private val runningState = MutableStateFlow(false)
private val completedState = MutableStateFlow(CompletedCycle(0, null))
@@ -96,6 +101,9 @@ class SyncEngine(
Step.Continue -> {
val pushError = (push as? Step.ContinueWithError)?.code
updateState { it.copy(lastSuccessAt = clock.nowTimestamp(), lastErrorCode = pushError) }
// CP-9 (D-173): card link changes go out after the household push, so their expenses exist on the
// server first. The card cycle's own result never changes the household outcome (§14.4.1).
cards.cycle()
return SyncOutcome.Success
}
is Step.ContinueWithError -> return finish(SyncOutcome.Stopped(pull.code))
@@ -377,6 +385,8 @@ class SyncEngine(
private suspend fun recoverEpoch(epoch: String?) {
val now = clock.nowMillis()
db.syncState().upsert(state().copy(cursor = 0, epoch = epoch))
// §14.2.4: a household epoch change also drops the card cache (pending rows kept) and pulls it from 0.
cards.dropCache()
for (e in db.expenses().all()) db.expenses().markPending(e.id, ids.newId(), now)
for (s in db.settlements().all()) db.settlements().markPending(s.id, ids.newId(), now)
}
@@ -0,0 +1,171 @@
package se.hagstedt.kvitt.data
import io.ktor.client.request.HttpRequestData
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import se.hagstedt.kvitt.data.api.CardAttemptDto
import se.hagstedt.kvitt.data.api.CardAuthRequest
import se.hagstedt.kvitt.data.api.CardConnectionDto
import se.hagstedt.kvitt.data.api.CardProviderDto
import se.hagstedt.kvitt.data.api.CardProvidersResponse
import se.hagstedt.kvitt.data.api.CardPurchaseDto
import se.hagstedt.kvitt.data.api.CardPurchaseUpdateRequest
import se.hagstedt.kvitt.data.api.CardRefreshResponse
import se.hagstedt.kvitt.data.api.CardSyncPage
import se.hagstedt.kvitt.data.api.ErrorDto
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.PatchCardConnectionRequest
import se.hagstedt.kvitt.data.util.Clock
import se.hagstedt.kvitt.data.util.Timestamps
/**
* The server side of E-36 to E-44 as far as the phone can observe it (§14.3.4, §9.12): one `card_sequence`, E-40 pages
* with `has_more` and tombstones, E-44's rules of §9.6 (`expense_id` must name a non-deleted expense of [household],
* one purchase per expense, credits take no change). [supported] false is a server before schema 4: every card path
* answers 404 `not_found`. Tests script deviations with [putOverride] and [attemptStatus].
*/
class FakeCardServer(private val clock: Clock, private val household: FakeServer) {
var supported = false
var seq = 0L
val connections = linkedMapOf<String, CardConnectionDto>()
val purchases = linkedMapOf<String, CardPurchaseDto>()
val providers = mutableListOf(CardProviderDto("sj_prio_mastercard", "SJ Prio Mastercard", "SEB Kort", "sj_prio_mastercard", true))
var maxPage: Int? = null
val pulls = mutableListOf<Long>()
val puts = mutableListOf<Pair<String, CardPurchaseUpdateRequest>>()
val refreshes = mutableListOf<String>()
var putOverride: ((String, CardPurchaseUpdateRequest) -> Pair<HttpStatusCode, String>?)? = null
var attemptStatus: String = "pending"
var attemptFailure: String? = null
var refreshFetching = false
var authRequests = mutableListOf<CardAuthRequest>()
fun next() = ++seq
fun addConnection(id: String = CONNECTION, status: String = "active", validUntil: String? = "2027-04-22T10:00:00.000Z", lastFetchAt: String? = T0): CardConnectionDto {
val dto = CardConnectionDto(id, "sj_prio_mastercard", status, 30, validUntil, lastFetchAt, null, false, T0, T0, next())
connections[id] = dto
return dto
}
fun addPurchase(
id: String,
amountOre: Long? = 41_200,
date: String = "2026-10-16",
merchant: String = "ICA MAXI LINDHAGEN",
direction: String = "debit",
status: String = "booked",
expenseId: String? = null,
ignored: Boolean = false,
currency: String? = null,
): CardPurchaseDto {
val dto = CardPurchaseDto(
id, CONNECTION, status, direction, date, if (status == "booked") date else null, amountOre,
if (amountOre == null) "12.50" else null, currency, merchant, expenseId, ignored, T0, T0, null, next(),
)
purchases[id] = dto
return dto
}
/** A tombstone with its content erased (§14.1.3). */
fun deletePurchase(id: String) {
val p = purchases.getValue(id)
purchases[id] = p.copy(merchant = "", amountOre = null, expenseId = null, deletedAt = T0, cardVersion = next())
}
fun handle(request: HttpRequestData, path: String, body: String): Pair<HttpStatusCode, String> {
if (!supported) return error(HttpStatusCode.NotFound, "not_found")
val method = request.method
return when {
path == "/cards/providers" && method == HttpMethod.Get -> ok(KvittJson.encodeToString(CardProvidersResponse.serializer(), CardProvidersResponse(providers)))
path == "/cards/sync" && method == HttpMethod.Get -> sync(request)
path == "/cards/auth" && method == HttpMethod.Post -> {
authRequests += KvittJson.decodeFromString(CardAuthRequest.serializer(), body)
created(attempt("https://bank.example/auth?x=1"))
}
path.startsWith("/cards/auth/") && method == HttpMethod.Get -> ok(attempt(null))
path.startsWith("/cards/purchases/") && method == HttpMethod.Put -> put(path.removePrefix("/cards/purchases/"), KvittJson.decodeFromString(CardPurchaseUpdateRequest.serializer(), body))
path.startsWith("/cards/connections/") && path.endsWith("/refresh") && method == HttpMethod.Post -> refresh(path.removePrefix("/cards/connections/").removeSuffix("/refresh"))
path.startsWith("/cards/connections/") && method == HttpMethod.Patch -> patch(path.removePrefix("/cards/connections/"), KvittJson.decodeFromString(PatchCardConnectionRequest.serializer(), body))
path.startsWith("/cards/connections/") && method == HttpMethod.Delete ->
disconnect(path.removePrefix("/cards/connections/"), request.url.parameters["erase_handled"] == "true")
else -> error(HttpStatusCode.NotFound, "not_found")
}
}
private fun sync(request: HttpRequestData): Pair<HttpStatusCode, String> {
val since = request.url.parameters["since"]?.toLong() ?: 0
val limit = minOf(request.url.parameters["limit"]?.toInt() ?: 500, maxPage ?: Int.MAX_VALUE)
pulls += since
if (since > seq) return error(HttpStatusCode.Conflict, "cursor_ahead", "since")
val rows = (connections.values.map { it.cardVersion to (it as Any) } + purchases.values.map { it.cardVersion to (it as Any) })
.filter { it.first > since }.sortedBy { it.first }
val page = rows.take(limit)
val next = page.lastOrNull()?.first ?: since
val dto = CardSyncPage(
since, next, rows.size > page.size, Timestamps.format(clock.nowMillis()),
page.map { it.second }.filterIsInstance<CardConnectionDto>(), page.map { it.second }.filterIsInstance<CardPurchaseDto>(),
)
return ok(KvittJson.encodeToString(CardSyncPage.serializer(), dto))
}
private fun put(id: String, request: CardPurchaseUpdateRequest): Pair<HttpStatusCode, String> {
puts += id to request
putOverride?.invoke(id, request)?.let { return it }
val p = purchases[id]?.takeIf { it.deletedAt == null } ?: return error(HttpStatusCode.NotFound, "not_found")
val expenseId = request.expenseId
if (expenseId != null && household.expenses[expenseId]?.takeIf { it.deletedAt == null } == null) {
return error(HttpStatusCode.NotFound, "not_found", "expense_id")
}
if (request.ignored && expenseId != null) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "ignored")
if (p.direction == "credit" && (expenseId != null || request.ignored)) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "expense_id")
if (expenseId != null && purchases.values.any { it.id != id && it.expenseId == expenseId }) return error(HttpStatusCode.Conflict, "expense_already_linked", "expense_id")
val changed = p.expenseId != expenseId || p.ignored != request.ignored
val next = if (changed) p.copy(expenseId = expenseId, ignored = request.ignored, cardVersion = next()) else p
purchases[id] = next
return ok(KvittJson.encodeToString(CardPurchaseDto.serializer(), next))
}
private fun refresh(id: String): Pair<HttpStatusCode, String> {
refreshes += id
val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
if (c.status != "active") return error(HttpStatusCode.Conflict, "card_consent_expired")
val updated = c.copy(lastFetchAt = Timestamps.format(clock.nowMillis()), fetching = refreshFetching, cardVersion = next())
connections[id] = updated
return ok(KvittJson.encodeToString(CardRefreshResponse.serializer(), CardRefreshResponse(updated, !refreshFetching)))
}
private fun patch(id: String, request: PatchCardConnectionRequest): Pair<HttpStatusCode, String> {
val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
if (request.historyDays > c.historyDays) return error(HttpStatusCode.UnprocessableEntity, "validation_failed", "history_days")
val updated = c.copy(historyDays = request.historyDays, cardVersion = next())
connections[id] = updated
return ok(KvittJson.encodeToString(CardConnectionDto.serializer(), updated))
}
/** CP-12: the purchases neither linked nor ignored are deleted, and the handled ones too with [eraseHandled]. */
private fun disconnect(id: String, eraseHandled: Boolean): Pair<HttpStatusCode, String> {
val c = connections[id] ?: return error(HttpStatusCode.NotFound, "not_found")
purchases.values.filter { it.deletedAt == null && (eraseHandled || (it.expenseId == null && !it.ignored)) }.forEach { deletePurchase(it.id) }
val updated = c.copy(status = "disconnected", cardVersion = next())
connections[id] = updated
return ok(KvittJson.encodeToString(CardConnectionDto.serializer(), updated))
}
private fun attempt(authUrl: String?): String {
val connection = if (attemptStatus == "connected") connections[CONNECTION] ?: addConnection(lastFetchAt = null) else null
val dto = CardAttemptDto(ATTEMPT, "sj_prio_mastercard", attemptStatus, attemptFailure, authUrl, "2026-10-17T10:15:00.000Z", connection)
return KvittJson.encodeToString(CardAttemptDto.serializer(), dto)
}
private fun ok(body: String) = HttpStatusCode.OK to body
private fun created(body: String) = HttpStatusCode.Created to body
private fun error(status: HttpStatusCode, code: String, field: String? = null) =
status to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto(code, code, field))
companion object {
const val T0 = "2026-10-15T08:00:00.000Z"
const val CONNECTION = "0193c000-0000-7000-8000-000000000001"
const val ATTEMPT = "0193c0a0-0000-7000-8000-000000000001"
}
}
@@ -59,6 +59,9 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
var apkBody: ByteArray? = null
var restHandler: ((HttpRequestData, String) -> Pair<HttpStatusCode, String>?)? = null
/** E-36 to E-44 (card purchases); without card support every card path answers 404 `not_found`. */
val cards = FakeCardServer(clock, this)
fun next() = ++seq
fun addUser(id: String, role: String, name: String) {
@@ -92,6 +95,11 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
failStatus?.let { (status, code) -> return scope.json(status, KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto(code, code, null))) }
val path = request.url.encodedPath
restHandler?.invoke(request, path)?.let { (status, body) -> return scope.json(status, body) }
if (path.startsWith("/cards/")) {
val text = if (request.method == HttpMethod.Get || request.method == HttpMethod.Delete) "" else String(request.body.toByteArray())
val (status, body) = cards.handle(request, path, text)
return scope.json(status, body)
}
val apk = apkBody
if (apk != null && path.startsWith("/apk/files/")) {
return scope.respond(apk, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType to listOf("application/vnd.android.package-archive")))
@@ -119,7 +127,7 @@ class FakeServer(private val clock: Clock, val epoch: String = "epoch-1") {
}
}
private fun MockRequestHandleScope.json(status: HttpStatusCode, body: String) =
fun MockRequestHandleScope.json(status: HttpStatusCode, body: String) =
respond(body, status, headersOf(HttpHeaders.ContentType to listOf("application/json; charset=utf-8"), "X-Kvitt-Server-Version" to listOf("0.1.0")))
fun page(since: Long, limit: Int): PullPage {
@@ -5,6 +5,7 @@ import se.hagstedt.kvitt.data.api.ExpensePushEntity
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.MutationDto
import se.hagstedt.kvitt.data.api.SplitDto
import se.hagstedt.kvitt.data.repo.CardRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
import se.hagstedt.kvitt.data.repo.SettlementRepositoryImpl
@@ -57,3 +58,5 @@ fun MutationDto.asStoredExpense(version: Long, actor: String = Harness.ME): Expe
fun runSync(block: suspend kotlinx.coroutines.CoroutineScope.() -> Unit) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.withTimeout(60_000, block) }
}
fun Harness.cardRepo() = CardRepositoryImpl(db, local, api, gate, engine.cards, prefs, clock, writes, scope)
@@ -90,6 +90,15 @@ internal fun serializerFor(path: String): KSerializer<*>? {
33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer()
34 -> TrendsResponse.serializer()
35 -> ForecastResponse.serializer()
// Card purchases (v0.35, K1's examples): E-39 is HTML and has none.
36 -> CardProvidersResponse.serializer()
37 -> if (request) CardAuthRequest.serializer() else CardAttemptDto.serializer()
38 -> CardAttemptDto.serializer()
40 -> CardSyncPage.serializer()
41 -> if (request) PatchCardConnectionRequest.serializer() else CardConnectionDto.serializer()
42 -> CardRefreshResponse.serializer()
43 -> CardConnectionDto.serializer()
44 -> if (request) CardPurchaseUpdateRequest.serializer() else CardPurchaseDto.serializer()
else -> null
}
}
@@ -22,8 +22,8 @@ import java.io.File
* Room migration test infrastructure (§14.2.3, CLAUDE.md: every schema change gets a migration and a test). A database
* is created at any exported version straight from `android/data/schemas/<n>.json` (its `createSql` and setup
* queries), filled with user data, then opened by the current [KvittDatabase] with [KvittMigrations.ALL]: Room runs the
* migrations and verifies the result against the compiled schema (identity hash and table info). At version 1 there
* are no migrations; the same path proves the committed `1.json` matches the entities.
* migrations and verifies the result against the compiled schema (identity hash and table info). The same path from
* the newest version proves its committed JSON matches the entities.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
@@ -60,8 +60,102 @@ class MigrationTest {
}
}
/**
* §14.2.3 (D-173): a version 1 database holding users, accounts, categories, expenses, settlements, audit entries, a
* pending expense and a rejection is migrated by `MIGRATION_1_2`; every row is kept and the card tables exist exactly
* as in the exported `2.json` (Room also validates them against the compiled schema when it opens), with the
* `card_sync_state` row `id` 1, `cursor` 0.
*/
@Test
fun migration1To2_keepsEveryRow_andCreatesTheCardTablesOf2Json() = runSync {
val name = "migration-1-2.db"
val file = RuntimeEnvironment.getApplication().getDatabasePath(name)
file.parentFile?.mkdirs()
file.delete()
createAt(1, file) { sqlite ->
sqlite.execSQL("INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version, local_updated_at) VALUES ('u2', 'member', 'Anna', 3, 't', 't', 2, 0)")
sqlite.execSQL("INSERT INTO accounts (id, name, created_by_user_id, created_at, updated_at, archived_at, deleted_at, server_version, local_updated_at) VALUES ('a1', 'Vardag', 'u1', 't', 't', NULL, NULL, 3, 0)")
sqlite.execSQL("INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES ('a1', 'u1', 6000), ('a1', 'u2', 4000)")
sqlite.execSQL(
"INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version, local_updated_at) " +
"VALUES ('c1', 'groceries', NULL, 'shopping_cart', 'green', 10, 't', 't', NULL, NULL, 4, 0)",
)
sqlite.execSQL(
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, recurring_template_id, recurring_period, " +
"created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, server_version, pending, pending_mutation_id, local_updated_at) " +
"VALUES ('e2', 'a1', 'c1', 9900, 'Coop', 'kvitto', '2026-10-15', 'u2', 'default', NULL, NULL, 'u2', 'u2', 't', 't', NULL, 5, 0, NULL, 0)",
)
sqlite.execSQL(
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, " +
"server_version, pending, pending_mutation_id, local_updated_at) VALUES ('s1', 'u2', 'u1', 80000, '2026-10-16', '', 'u2', 'u2', 't', 't', NULL, 6, 0, NULL, 0)",
)
sqlite.execSQL("INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES ('s1', 'a1', 80000)")
sqlite.execSQL(
"INSERT INTO audit_log (id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, server_version) " +
"VALUES ('l1', 'expense', 'e2', 'create', 'app', 'u2', 't', 't', '[]', 7)",
)
sqlite.execSQL(
"INSERT INTO sync_rejections (mutation_id, entity_type, entity_id, reason, message, rejected_payload, server_current, rejected_at, dismissed) " +
"VALUES ('m9', 'expense', 'e9', 'stale', 'stale', '{}', NULL, 't', 0)",
)
sqlite.execSQL("INSERT INTO sync_state (id, cursor, epoch, last_success_at, last_attempt_at, last_error_code, clock_skew) VALUES (1, 7, 'epoch-1', 't', 't', NULL, 0)")
}
val db = KvittDatabase.build(RuntimeEnvironment.getApplication(), AndroidSQLiteDriver(), name)
try {
assertEquals(2, db.users().all().size)
assertEquals(listOf("a1"), db.accounts().observeAll().first().map { it.id })
assertEquals(2, db.accounts().observeShares().first().size)
assertEquals("c1", db.categories().get("c1")?.id)
assertEquals(true, db.expenses().get("e1")?.pending)
assertEquals("kvitto", db.expenses().get("e2")?.notes)
assertEquals(listOf(80000L), db.settlements().allocations("s1").map { it.amountOre })
assertEquals("e2", db.audit().get("l1")?.entityId)
assertEquals(listOf("m9"), db.rejections().all().map { it.mutationId })
assertEquals(7L, db.syncState().get()?.cursor)
assertEquals(CardSyncStateEntity(1, 0, null), db.cards().syncState())
assertEquals(emptyList<CardConnectionEntity>(), db.cards().connections())
assertEquals(emptyList<CardPurchaseEntity>(), db.cards().purchases())
} finally {
db.close()
}
// The migrated tables and indices are the exported 2.json's, statement for statement.
val expected = schemaStatements(2).filterKeys { it.startsWith("card_") || it.startsWith("index_card_") }
assertEquals(setOf("card_connections", "card_purchases", "card_sync_state", "index_card_purchases_purchase_date", "index_card_purchases_expense_id", "index_card_purchases_pending"), expected.keys)
val sqlite = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
try {
expected.forEach { (objectName, sql) ->
sqlite.rawQuery("SELECT sql FROM sqlite_master WHERE name = ?", arrayOf(objectName)).use { c ->
assertTrue("$objectName exists", c.moveToFirst())
assertEquals(objectName, sql.normalised(), c.getString(0).normalised())
}
}
} finally {
sqlite.close()
file.delete()
}
}
/** Every table's and index's `createSql` of the exported [version], by name. */
private fun schemaStatements(version: Int): Map<String, String> {
val schema = Json.parseToJsonElement(File(schemas, "$version.json").readText()).jsonObject.getValue("database").jsonObject
return buildMap {
for (entity in schema.getValue("entities").jsonArray) {
val o = entity.jsonObject
val table = o.getValue("tableName").jsonPrimitive.content
put(table, o.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table))
o["indices"]?.jsonArray?.forEach { index ->
val i = index.jsonObject
put(i.getValue("name").jsonPrimitive.content, i.getValue("createSql").jsonPrimitive.content.replace("\${TABLE_NAME}", table))
}
}
}
}
/** SQLite stores a statement without `IF NOT EXISTS`. */
private fun String.normalised(): String = replace("IF NOT EXISTS ", "").trim()
/** A database file exactly as Room would have created it at [version], with one row of user data. */
private fun createAt(version: Int, file: File) {
private fun createAt(version: Int, file: File, extra: (SQLiteDatabase) -> Unit = {}) {
val schema = Json.parseToJsonElement(File(schemas, "$version.json").readText()).jsonObject.getValue("database").jsonObject
assertEquals(version, schema.getValue("version").jsonPrimitive.int)
val sqlite = SQLiteDatabase.openOrCreateDatabase(file, null)
@@ -82,6 +176,7 @@ class MigrationTest {
"VALUES ('e1', 'a1', 'c1', 41200, 'ICA', '', '2026-10-16', 'u1', 'default', NULL, NULL, NULL, NULL, 't', 't', NULL, NULL, 1, 'm1', 0)",
)
sqlite.execSQL("INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES ('e1', 'u1', 20600, 5000), ('e1', 'u2', 20600, 5000)")
extra(sqlite)
sqlite.version = version
} finally {
sqlite.close()
@@ -0,0 +1,258 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.FakeCardServer.Companion.ATTEMPT
import se.hagstedt.kvitt.data.FakeCardServer.Companion.CONNECTION
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
import se.hagstedt.kvitt.data.Harness.Companion.ME
import se.hagstedt.kvitt.data.Harness.Companion.OTHER
import se.hagstedt.kvitt.data.cardRepo
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.data.savedId
import se.hagstedt.kvitt.data.util.Timestamps
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardFetchError
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
/** `CardRepositoryImpl` (SPEC §14.2.4, §9.12 CP-6 to CP-12) on Room and the fake card server. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class CardRepositoryTest {
private lateinit var h: Harness
@Before
fun setUp() {
h = Harness().ready()
h.server.cards.supported = true
h.server.cards.addConnection()
}
@After
fun tearDown() = h.close()
private val cards get() = h.server.cards
private suspend fun pulled() {
h.engine.cards.cycle()
}
@Test
fun theFiltersSplitThePurchases_newestFirstByDay_withCounts() = runSync {
cards.addPurchase("p1", date = "2026-10-14")
cards.addPurchase("p2", date = "2026-10-16", status = "pending")
cards.addPurchase("p3", date = "2026-10-16", ignored = true)
cards.addPurchase("p4", date = "2026-10-15", direction = "credit")
pulled()
val repo = h.cardRepo()
val toHandle = repo.cards(CardFilter.TO_HANDLE).first()
assertEquals(listOf("2026-10-16", "2026-10-14"), toHandle.days.map { it.date })
assertEquals(listOf("p2", "p1"), toHandle.days.flatMap { d -> d.rows.map { it.purchase.id.value } })
assertEquals(mapOf(CardFilter.TO_HANDLE to 2, CardFilter.LINKED to 0, CardFilter.IGNORED to 1, CardFilter.ALL to 4), toHandle.counts)
assertEquals(listOf("p3"), repo.cards(CardFilter.IGNORED).first().days.flatMap { d -> d.rows.map { it.purchase.id.value } })
// A credit purchase shows only under "Alla" (CP-8).
// Within a day, the newest created first, then the higher id.
assertEquals(listOf("p3", "p2", "p4", "p1"), repo.cards(CardFilter.ALL).first().days.flatMap { d -> d.rows.map { it.purchase.id.value } })
assertEquals(CONNECTION, toHandle.connection?.id?.value)
assertEquals("SJ Prio Mastercard", repo.providers().let { (it as OnlineResult.Ok).value.single().name })
assertEquals("SJ Prio Mastercard", repo.cards(CardFilter.TO_HANDLE).first().provider?.name)
}
@Test
fun matches_sameAmountWithinThreeDays_notLinkedByAnotherPurchaseOfTheViewer() = runSync {
val repo = h.cardRepo()
val near = h.expenseRepo().save(draft("ICA", amountOre = 41_200, date = "2026-10-14")).savedId()
val far = h.expenseRepo().save(draft("ICA", amountOre = 41_200, date = "2026-10-12")).savedId()
h.expenseRepo().save(draft("Coop", amountOre = 41_201, date = "2026-10-16")).savedId()
val linkedElsewhere = h.expenseRepo().save(draft("ICA igen", amountOre = 41_200, date = "2026-10-16")).savedId()
h.engine.sync()
cards.addPurchase("p1", date = "2026-10-16")
cards.addPurchase("p2", date = "2026-10-01", expenseId = linkedElsewhere.value)
pulled()
val row = repo.cards(CardFilter.TO_HANDLE).first().days.single().rows.single()
assertEquals(listOf(near), row.matches.map { it.id })
assertEquals("Vardag", row.matches.single().accountName)
assertEquals(ME, row.matches.single().payer.id.value)
val detail = repo.purchase(CardPurchaseId("p1")).first()
// "Välj en annan utgift": any amount within 14 days, not linked by another purchase, newest first.
assertEquals(3, detail?.others?.size)
assertTrue(detail?.others?.none { it.id == linkedElsewhere } == true)
assertTrue(detail?.others?.any { it.id == far } == true)
// Under "Kopplade" the line names the expense and its account.
val linked = repo.cards(CardFilter.LINKED).first().days.single().rows.single().purchase.linked
assertEquals("ICA igen", linked?.description)
assertEquals("Vardag", linked?.accountName)
assertEquals("p2", repo.purchaseForExpense(linkedElsewhere).first()?.id?.value)
}
@Test
fun prefill_appliesCp9() = runSync {
val long = "A".repeat(90) + " " + "B".repeat(30)
cards.addPurchase("p1", merchant = long, date = "2026-10-15")
cards.addPurchase("p2", amountOre = null, currency = "EUR")
cards.addPurchase("p3", direction = "credit")
pulled()
val repo = h.cardRepo()
val draft = repo.prefill(CardPurchaseId("p1"), null)
assertNull(draft?.id)
assertEquals(41_200L, draft?.amountOre)
assertEquals(long.take(100), draft?.description)
assertEquals("2026-10-15", draft?.date)
assertEquals(UserId(ME), draft?.payer)
// AC-8.6: no account used on this phone yet, so the oldest active account and its `other` category.
assertEquals(AccountId(ACCOUNT), draft?.accountId)
assertEquals(CategoryId(OTHER), draft?.categoryId)
assertEquals(SplitMode.DEFAULT, draft?.mode)
assertEquals("", draft?.notes)
// A foreign-currency purchase leaves the amount empty; a credit and an unknown purchase give no draft.
assertNull(repo.prefill(CardPurchaseId("p2"), AccountId(ACCOUNT))?.amountOre)
assertNull(repo.prefill(CardPurchaseId("p3"), null))
assertNull(repo.prefill(CardPurchaseId("nope"), null))
}
@Test
fun prefill_takesTheAccountItWasOpenedFrom_andTheLastCategoryUsedThere() = runSync {
h.expenseRepo().save(draft("ICA")).savedId()
cards.addPurchase("p1")
pulled()
val draft = h.cardRepo().prefill(CardPurchaseId("p1"), AccountId(ACCOUNT))
assertEquals(AccountId(ACCOUNT), draft?.accountId)
assertEquals(CategoryId(GROCERIES), draft?.categoryId)
}
@Test
fun linkUnlinkIgnore_areLocalAtOnce_pending_andAskForASync() = runSync {
cards.addPurchase("p1")
pulled()
val repo = h.cardRepo()
val expense = h.expenseRepo().save(draft("ICA")).savedId()
val writes = h.localWrites
repo.link(CardPurchaseId("p1"), expense)
assertEquals(expense.value, h.db.cards().purchase("p1")?.expenseId)
assertEquals(true, h.db.cards().purchase("p1")?.pending)
assertEquals(writes + 1, h.localWrites)
assertEquals(1, repo.cards(CardFilter.LINKED).first().counts[CardFilter.LINKED])
repo.unlink(CardPurchaseId("p1"))
assertNull(h.db.cards().purchase("p1")?.expenseId)
repo.setIgnored(CardPurchaseId("p1"), true)
assertEquals(true, h.db.cards().purchase("p1")?.ignored)
assertEquals(1, repo.cards(CardFilter.IGNORED).first().counts[CardFilter.IGNORED])
assertEquals(emptyList<String>(), repo.toHandle().first().map { it.id.value })
// Offline: nothing reaches the network for these (CP-9).
h.monitor.state.value = false
val requests = h.requests.size
repo.setIgnored(CardPurchaseId("p1"), false)
assertEquals(requests, h.requests.size)
assertEquals(listOf("p1"), repo.toHandle().first().map { it.id.value })
}
@Test
fun aLocalExpenseDeleteUnlinksItsPurchase() = runSync {
cards.addPurchase("p1")
pulled()
val expense = h.expenseRepo().save(draft("ICA")).savedId()
h.cardRepo().link(CardPurchaseId("p1"), expense)
h.expenseRepo().delete(expense)
assertNull(h.db.cards().purchase("p1")?.expenseId)
assertEquals(listOf("p1"), h.cardRepo().toHandle().first().map { it.id.value })
}
@Test
fun refresh_callsE42ThenPulls_offlineIsOffline() = runSync {
pulled()
val repo = h.cardRepo()
cards.addPurchase("p1")
assertEquals(OnlineResult.Ok(Unit), repo.refresh())
assertEquals(listOf(CONNECTION), cards.refreshes)
assertEquals(listOf("p1"), h.db.cards().purchases().map { it.id })
assertEquals(Timestamps.format(h.clock.now), h.db.cards().connection(CONNECTION)?.lastFetchAt)
h.monitor.state.value = false
assertEquals(OnlineResult.Offline, repo.refresh())
assertEquals(1, cards.refreshes.size)
}
@Test
fun connection_expiresSoonFrom14DaysBefore_andAFetchErrorReads() = runSync {
cards.connections[CONNECTION] = cards.connections.getValue(CONNECTION).copy(validUntil = "2026-10-31T09:00:00.000Z", lastFetchError = "something_new", cardVersion = cards.next())
pulled()
val connection = h.cardRepo().cards(CardFilter.TO_HANDLE).first().connection
assertEquals(true, connection?.expiresSoon)
assertEquals(CardFetchError.FAILED, connection?.lastFetchError)
cards.connections[CONNECTION] = cards.connections.getValue(CONNECTION).copy(validUntil = "2026-11-01T11:00:00.000Z", lastFetchError = "rate_limited", cardVersion = cards.next())
pulled()
val later = h.cardRepo().cards(CardFilter.TO_HANDLE).first().connection
assertEquals(false, later?.expiresSoon)
assertEquals(CardFetchError.RATE_LIMITED, later?.lastFetchError)
}
@Test
fun connect_startsAnAttempt_andAConnectedAttemptWritesTheConnection() = runSync {
cards.connections.clear()
val repo = h.cardRepo()
assertFalse(repo.hasConnection.first())
val start = repo.startConnect("sj_prio_mastercard", 90)
assertEquals(ATTEMPT, (start as OnlineResult.Ok).value.attemptId)
assertEquals("https://bank.example/auth?x=1", start.value.authUrl)
assertEquals(90, cards.authRequests.single().historyDays)
assertEquals("android", cards.authRequests.single().client)
assertEquals(OnlineResult.Ok(CardAttemptState.Pending), repo.attempt(ATTEMPT))
cards.attemptStatus = "failed"
cards.attemptFailure = "cancelled"
assertEquals(OnlineResult.Ok(CardAttemptState.Failed("cancelled")), repo.attempt(ATTEMPT))
cards.attemptStatus = "connected"
cards.attemptFailure = null
val connected = repo.attempt(ATTEMPT)
assertEquals(CardConnectionStatus.ACTIVE, ((connected as OnlineResult.Ok).value as CardAttemptState.Connected).connection.status)
assertTrue(repo.hasConnection.first())
cards.attemptStatus = "expired"
assertEquals(OnlineResult.Ok(CardAttemptState.Expired), repo.attempt(ATTEMPT))
}
@Test
fun historyAndDisconnect_areOnline_andDisconnectKeepsTheHandledPurchases() = runSync {
cards.addPurchase("p1")
cards.addPurchase("p2", ignored = true)
pulled()
val repo = h.cardRepo()
assertEquals(OnlineResult.Ok(Unit), repo.setHistoryDays(CardConnectionId(CONNECTION), 7))
assertEquals(7, h.db.cards().connection(CONNECTION)?.historyDays)
assertEquals("validation_failed", (repo.setHistoryDays(CardConnectionId(CONNECTION), 180) as OnlineResult.Failed).code)
assertEquals(OnlineResult.Ok(Unit), repo.disconnect(CardConnectionId(CONNECTION), eraseHandled = false))
assertFalse(repo.hasConnection.first())
assertEquals(listOf("p2"), h.db.cards().purchases().map { it.id })
assertNull(repo.cards(CardFilter.TO_HANDLE).first().connection)
h.monitor.state.value = false
assertEquals(OnlineResult.Offline, repo.disconnect(CardConnectionId(CONNECTION), eraseHandled = true))
}
@Test
fun anExpenseLinkedOnTheServerButNotOnThePhoneShowsNoLinkedLine() = runSync {
cards.addPurchase("p1", expenseId = "0192ffff-0000-7000-8000-00000000ffff")
pulled()
val row = h.cardRepo().cards(CardFilter.LINKED).first().days.single().rows.single()
assertNull(row.purchase.linked)
assertNull(h.cardRepo().purchaseForExpense(ExpenseId("0192ffff-0000-7000-8000-00000000ffff")).first()?.linked)
}
}
@@ -0,0 +1,222 @@
package se.hagstedt.kvitt.data.sync
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.FakeCardServer.Companion.CONNECTION
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.addExpense
import se.hagstedt.kvitt.data.api.ErrorDto
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.cardRepo
import se.hagstedt.kvitt.data.cards.CardCycle
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.ExpenseId
/**
* The card cycle (SPEC §14.2.4 `:data`, §14.2.1, CP-9) against the fake card server behind Ktor's MockEngine: paging,
* tombstones, pending rows skipped on pull, the E-44 outcomes, a server without card support, `cursor_ahead` and the
* household epoch change, and its place after the household push.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class CardSyncTest {
private lateinit var h: Harness
@Before
fun setUp() {
h = Harness().ready()
h.server.cards.supported = true
}
@After
fun tearDown() = h.close()
private val cards get() = h.server.cards
@Test
fun pull_pagesUntilHasMoreFalse_storesRowsAndTheCursor() = runSync {
cards.addConnection()
cards.addPurchase("p1")
cards.addPurchase("p2")
cards.addPurchase("p3")
cards.maxPage = 2
assertEquals(CardCycle.Success, h.engine.cards.cycle())
// Four rows (the connection and three purchases) in pages of two.
assertEquals(listOf(0L, 2L), cards.pulls)
assertEquals(listOf("p1", "p2", "p3"), h.db.cards().purchases().map { it.id }.sorted())
assertEquals(CONNECTION, h.db.cards().connections().single().id)
assertEquals(4L, h.db.cards().syncState()?.cursor)
assertTrue(h.db.cards().syncState()?.lastSuccessAt != null)
}
@Test
fun pull_aTombstoneDeletesTheLocalRow_andAnOlderVersionIsNotApplied() = runSync {
cards.addConnection()
cards.addPurchase("p1")
cards.addPurchase("p2")
h.engine.cards.cycle()
cards.deletePurchase("p1")
h.engine.cards.cycle()
assertEquals(listOf("p2"), h.db.cards().purchases().map { it.id })
}
@Test
fun pull_skipsAPendingLocalRow() = runSync {
cards.addConnection()
cards.addPurchase("p1", merchant = "ICA")
h.engine.cards.cycle()
h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
// The server changes the row meanwhile; the pending local change is not overwritten by the pull.
cards.purchases["p1"] = cards.purchases.getValue("p1").copy(merchant = "ICA MAXI", cardVersion = cards.next())
cards.putOverride = { _, _ -> HttpStatusCode.ServiceUnavailable to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("unavailable", "x", null)) }
assertEquals(CardCycle.Failed("unavailable"), h.engine.cards.cycle())
cards.putOverride = null
val local = h.db.cards().purchase("p1")
assertEquals(true, local?.pending)
assertEquals(true, local?.ignored)
assertEquals("ICA", local?.merchant)
}
@Test
fun push_200OverwritesTheRowAndClearsPending() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.cards.cycle()
h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
assertEquals(CardCycle.Success, h.engine.cards.cycle())
assertEquals(listOf("p1"), cards.puts.map { it.first })
assertEquals(true, cards.puts.single().second.ignored)
val local = h.db.cards().purchase("p1")
assertEquals(false, local?.pending)
assertEquals(cards.purchases.getValue("p1").cardVersion, local?.cardVersion)
}
@Test
fun push_aLinkToAnExpenseNotOnTheServerYetStaysPending_andGoesOutAfterTheHouseholdPush() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.cards.cycle()
val expense = h.addExpense("ICA MAXI LINDHAGEN")
h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
// The card cycle alone: the expense is still only on the phone, E-44 answers not_found with field expense_id.
h.engine.cards.cycle()
assertEquals(true, h.db.cards().purchase("p1")?.pending)
assertEquals(expense, h.db.cards().purchase("p1")?.expenseId)
// A household cycle pushes the expense first, then the card cycle sends the link.
assertEquals(SyncOutcome.Success, h.engine.sync())
assertEquals(expense, cards.purchases.getValue("p1").expenseId)
assertEquals(false, h.db.cards().purchase("p1")?.pending)
val order = h.requests.map { it.method.value + " " + it.url.encodedPath }
assertTrue(order.indexOf("POST /sync") < order.lastIndexOf("PUT /cards/purchases/p1"))
}
@Test
fun push_notFoundForThePurchaseDeletesTheLocalRow() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.cards.cycle()
h.cardRepo().setIgnored(CardPurchaseId("p1"), true)
cards.purchases.remove("p1")
h.engine.cards.cycle()
assertNull(h.db.cards().purchase("p1"))
}
@Test
fun push_expenseAlreadyLinkedTakesTheServersStateAtTheNextPull() = runSync {
cards.addConnection()
val expense = h.addExpense("Coop")
h.engine.sync()
cards.addPurchase("p1")
cards.addPurchase("p2", expenseId = expense)
h.engine.cards.cycle()
h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
h.engine.cards.cycle()
assertEquals(1, cards.puts.size)
val local = h.db.cards().purchase("p1")
assertEquals(false, local?.pending)
// The server's row did not change, yet the phone has its state again: no link.
assertNull(local?.expenseId)
assertEquals(cards.purchases.getValue("p1").cardVersion, local?.cardVersion)
}
@Test
fun push_aLinkToAnExpenseDeletedOnThePhoneIsClearedBeforeItIsSent() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.cards.cycle()
val expense = h.addExpense("ICA")
h.cardRepo().link(CardPurchaseId("p1"), ExpenseId(expense))
h.expenseRepo().delete(ExpenseId(expense))
assertNull(h.db.cards().purchase("p1")?.expenseId)
h.engine.sync()
assertNull(cards.purchases.getValue("p1").expenseId)
assertEquals(false, h.db.cards().purchase("p1")?.pending)
}
@Test
fun aServerWithoutCardSupportAnswers404_theTablesStayEmpty_andTheHouseholdSyncSucceeds() = runSync {
cards.supported = false
cards.addConnection()
cards.addPurchase("p1")
assertEquals(CardCycle.NoSupport, h.engine.cards.cycle())
assertEquals(SyncOutcome.Success, h.engine.sync())
assertTrue(h.db.cards().purchases().isEmpty())
assertTrue(h.db.cards().connections().isEmpty())
assertFalse(h.cardRepo().hasConnection.first())
}
@Test
fun cursorAhead_dropsTheCardCacheKeepingPendingRows_andPullsFromZero() = runSync {
cards.addConnection()
cards.addPurchase("p1")
cards.addPurchase("p2")
h.engine.cards.cycle()
h.cardRepo().setIgnored(CardPurchaseId("p2"), true)
// The pending change cannot go out yet (it stays pending), and the phone's cursor is ahead of the server's.
cards.putOverride = { _, _ -> HttpStatusCode.NotFound to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("not_found", "x", "expense_id")) }
h.db.cards().upsertSyncState(requireNotNull(h.db.cards().syncState()).copy(cursor = 99))
cards.pulls.clear()
assertEquals(CardCycle.Success, h.engine.cards.cycle())
assertEquals(listOf(99L, 0L), cards.pulls)
assertEquals(setOf("p1", "p2"), h.db.cards().purchases().map { it.id }.toSet())
assertEquals(true, h.db.cards().purchase("p2")?.pending)
assertEquals(true, h.db.cards().purchase("p2")?.ignored)
assertEquals(CONNECTION, h.db.cards().connections().single().id)
}
@Test
fun aHouseholdEpochChangeDropsTheCardCache_andTheNextCycleRefillsIt() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.sync()
assertEquals(1, h.db.cards().purchases().size)
h.server.currentEpoch = "epoch-2"
cards.pulls.clear()
assertEquals(SyncOutcome.Success, h.engine.sync())
assertEquals(0L, cards.pulls.first())
assertEquals(listOf("p1"), h.db.cards().purchases().map { it.id })
}
@Test
fun theRevokeWipeDeletesTheCardTables() = runSync {
cards.addConnection()
cards.addPurchase("p1")
h.engine.cards.cycle()
h.wipe.wipe()
assertTrue(h.db.cards().purchases().isEmpty())
assertTrue(h.db.cards().connections().isEmpty())
assertNull(h.db.cards().syncState())
}
}