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