Coverage Summary for Class: TransactionRepositoryImplKt (com.georgeci.moneysurfer.data.repository)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| TransactionRepositoryImplKt |
100%
(5/5)
|
76.9%
(10/13)
|
82.4%
(14/17)
|
87.6%
(106/121)
|
| TransactionRepositoryImplKt$toDomainTotals$$inlined$groupingBy$1 |
0%
(0/3)
|
|
| Total |
62.5%
(5/8)
|
76.9%
(10/13)
|
82.4%
(14/17)
|
87.6%
(106/121)
|
package com.georgeci.moneysurfer.data.repository
import com.georgeci.moneysurfer.data.db.dao.TransactionDao
import com.georgeci.moneysurfer.data.db.entity.CategorizedTransactionEntity
import com.georgeci.moneysurfer.data.db.entity.TransactionEntity
import com.georgeci.moneysurfer.data.db.entity.TransactionTotalEntity
import com.georgeci.moneysurfer.domain.model.CategorizedTransaction
import com.georgeci.moneysurfer.domain.model.Transaction
import com.georgeci.moneysurfer.domain.model.TransactionTags
import com.georgeci.moneysurfer.domain.model.TransactionTotal
import com.georgeci.moneysurfer.domain.primitives.AccountId
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.primitives.RecurringRuleId
import com.georgeci.moneysurfer.domain.primitives.SplitId
import com.georgeci.moneysurfer.domain.primitives.TransactionId
import com.georgeci.moneysurfer.domain.primitives.TransactionStatus
import com.georgeci.moneysurfer.domain.primitives.TransactionType
import com.georgeci.moneysurfer.domain.primitives.TransferId
import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
import com.georgeci.moneysurfer.domain.repositories.TransactionRepository
import com.georgeci.moneysurfer.domain.sync.SyncEntityTypes
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
import com.georgeci.moneysurfer.sync.repository.MutationOperation
import com.georgeci.moneysurfer.sync.repository.OutboxEnqueuer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.koin.core.annotation.Single
// Mirrors [TransactionRepository]'s query surface plus the entity/domain mappers those overrides
// share; the count follows the interface it implements. See the note there.
@Suppress("TooManyFunctions")
@Single(binds = [TransactionRepository::class])
class TransactionRepositoryImpl(
private val dao: TransactionDao,
private val outboxEnqueuer: OutboxEnqueuer,
private val clock: ClockUseCase,
private val timeFormatter: TimeFormatter,
) : TransactionRepository {
override fun getAll(): Flow<List<Transaction>> =
dao.getAll().map { list -> list.map { it.toDomain() } }
override fun getByAccountId(accountId: AccountId): Flow<List<Transaction>> =
dao.getByAccountId(accountId.value).map { list -> list.map { it.toDomain() } }
override fun getCategorizedWindow(
accountId: AccountId?,
window: TransactionPeriodWindow,
limit: Int,
): Flow<List<CategorizedTransaction>> = dao.getCategorizedWindow(
accountId = accountId?.value,
from = window.from?.toString(),
to = window.to?.toString(),
limit = limit,
).map { list -> list.map { it.toDomain() } }
override fun getTotals(
accountId: AccountId?,
window: TransactionPeriodWindow,
): Flow<List<TransactionTotal>> = dao.getTotals(
accountId = accountId?.value,
from = window.from?.toString(),
to = window.to?.toString(),
).map { rows -> rows.toDomainTotals() }
override fun getByWorkspaceId(workspaceId: WorkspaceId): Flow<List<Transaction>> =
dao.getByWorkspaceId(workspaceId.value).map { list -> list.map { it.toDomain() } }
override suspend fun getById(id: TransactionId): Transaction? =
dao.getById(id.value)?.toDomain()
override suspend fun getByTransferId(transferId: TransferId): List<Transaction> =
dao.getByTransferId(transferId.value).map { it.toDomain() }
override suspend fun getBySplitId(splitId: SplitId): List<Transaction> =
dao.getBySplitId(splitId.value).map { it.toDomain() }
override suspend fun insert(transaction: Transaction) {
val entity = transaction.toEntity()
dao.insert(entity)
enqueueUpsert(entity, MutationOperation.INSERT)
}
/**
* `@Update` rewrites the whole row, so every column the domain model does not carry has to be
* read back from storage first or it is silently reset. `createdAt` is one; `deletedAt` is the
* other, and it is read through [TransactionDao.getByIdIncludingDeleted] precisely because a
* tombstoned row is invisible to the ordinary lookup — writing `Transaction`'s implicit "live"
* over it would undelete the row as a side effect of an unrelated edit. Lifting a tombstone is
* [restore]'s job and nothing else's.
*/
override suspend fun update(transaction: Transaction) {
val stored = dao.getByIdIncludingDeleted(transaction.id.value)
val entity = transaction.toEntity()
val updated = entity.copy(
createdAt = stored?.createdAt ?: entity.createdAt,
updatedAt = clock.now().toEpochMilliseconds(),
deletedAt = stored?.deletedAt,
)
dao.update(updated)
enqueueUpsert(updated, MutationOperation.UPDATE)
}
/**
* Writes a tombstone instead of dropping the row, then enqueues the same DELETE mutation the
* hard delete used to (issue #346) — the wire contract is unchanged, because Firestore has
* always taken deletes as a `deletedAt` patch rather than a document removal.
*
* The outbox entry is skipped when nothing was tombstoned. A second delete of the same id
* would otherwise push a second tombstone whose `deletedAt` is a later instant than the one
* peers already agreed on.
*/
override suspend fun delete(id: TransactionId) {
val existing = dao.getById(id.value) ?: return
val marked = dao.softDelete(id.value, clock.now().toEpochMilliseconds())
if (marked > 0) {
outboxEnqueuer.enqueueDelete(
entityType = SyncEntityTypes.TRANSACTION,
entityId = existing.id,
scopeKey = existing.workspaceId,
)
}
}
/**
* Clears the tombstone in place — one UPDATE, no re-insert — and pushes the result as an
* ordinary upsert. That upsert is what lifts the tombstone remotely too: the doc is written
* whole, with `deletedAt` back to null, so peers see a restored row rather than a new one
* appearing beside the delete they already applied.
*/
override suspend fun restore(id: TransactionId): Transaction? {
if (dao.restore(id.value, clock.now().toEpochMilliseconds()) == 0) return null
val restored = dao.getById(id.value) ?: return null
enqueueUpsert(restored, MutationOperation.UPDATE)
return restored.toDomain()
}
private suspend fun enqueueUpsert(entity: TransactionEntity, operation: MutationOperation) {
outboxEnqueuer.enqueueUpsert(
entityType = SyncEntityTypes.TRANSACTION,
entityId = entity.id,
scopeKey = entity.workspaceId,
operation = operation,
)
}
private fun TransactionEntity.toDomain() = Transaction(
id = TransactionId(id),
workspaceId = WorkspaceId(workspaceId),
accountId = AccountId(accountId),
money = Money.fromMinor(amount),
currencyCode = CurrencyCode(currencyCode),
categoryId = categoryId?.let(::CategoryId),
note = note,
merchant = merchant,
tags = tags.parseCsvColumn(),
operationAt = timeFormatter.parseInstant(operationAt),
operationDate = resolveOperationDate(operationDate, operationAt),
type = parseType(type, amount),
status = parseStatus(status),
createdAt = timeFormatter.parseInstant(createdAt),
updatedAt = timeFormatter.parseInstant(updatedAt),
transferId = transferId?.let(::TransferId),
splitId = splitId?.let(::SplitId),
recurringRuleId = recurringRuleId?.let(::RecurringRuleId),
)
// Re-projects the flat categorized row onto [TransactionEntity] so the domain
// mapping in [TransactionEntity.toDomain] stays the single source of truth.
private fun CategorizedTransactionEntity.toDomain() = CategorizedTransaction(
transaction = TransactionEntity(
id = id,
workspaceId = workspaceId,
accountId = accountId,
amount = amount,
currencyCode = currencyCode,
categoryId = categoryId,
note = note,
merchant = merchant,
tags = tags,
operationAt = operationAt,
operationDate = operationDate,
type = type,
status = status,
createdAt = createdAt,
updatedAt = updatedAt,
transferId = transferId,
splitId = splitId,
recurringRuleId = recurringRuleId,
).toDomain(),
categoryName = categoryName,
splitLegCount = splitLegCount,
)
private fun resolveOperationDate(stored: String, operationAt: Long) =
resolveLegacyOperationDate(timeFormatter, stored, operationAt)
private fun Transaction.toEntity() = TransactionEntity(
id = id.value,
workspaceId = workspaceId.value,
accountId = accountId.value,
amount = money.minor,
currencyCode = currencyCode.value,
categoryId = categoryId?.value,
note = note,
merchant = merchant,
// Normalized here rather than trusted from the caller: this is the last point before
// the CSV column, and a tag carrying the separator would come back as two tags.
tags = TransactionTags.normalize(tags).toCsvColumn(),
operationAt = timeFormatter.formatInstant(operationAt),
operationDate = timeFormatter.formatLocalDate(operationDate),
type = type.name,
status = status.name,
createdAt = timeFormatter.formatInstant(createdAt),
updatedAt = timeFormatter.formatInstant(updatedAt),
transferId = transferId?.value,
splitId = splitId?.value,
recurringRuleId = recurringRuleId?.value,
)
}
/**
* Folds the raw aggregation rows onto the domain types, merging the buckets that only exist
* because the SQL had to keep the amount sign separate to resolve the legacy `REGULAR` spelling
* (see [parseType]). Opening balances are already excluded by the query.
*/
private fun List<TransactionTotalEntity>.toDomainTotals(): List<TransactionTotal> = this
.groupingBy { row ->
parseType(row.type, if (row.isNegative) -1L else 1L) to CurrencyCode(row.currencyCode)
}
.fold(0L) { acc, row -> acc + row.total }
.map { (key, total) ->
val (type, currency) = key
TransactionTotal(type = type, currencyCode = currency, total = Money.fromMinor(total))
}
private fun parseType(raw: String, amount: Long): TransactionType =
when (raw) {
TransactionType.INCOME.name -> TransactionType.INCOME
TransactionType.EXPENSE.name -> TransactionType.EXPENSE
TransactionType.OPENING_BALANCE.name -> TransactionType.OPENING_BALANCE
"INITIAL_BALANCE" -> TransactionType.OPENING_BALANCE
"REGULAR" -> if (amount < 0) TransactionType.EXPENSE else TransactionType.INCOME
else -> TransactionType.EXPENSE
}
private fun parseStatus(raw: String): TransactionStatus =
runCatching { TransactionStatus.valueOf(raw) }.getOrDefault(TransactionStatus.ACTUAL)
// Legacy rows may have a blank `operationDate` (older schema). Resolve via UTC so the
// same epoch maps to the same business date on every device — matches the fallback in
// SyncDtoMappers and prevents cross-device divergence on push-back.
internal fun resolveLegacyOperationDate(
timeFormatter: TimeFormatter,
stored: String,
operationAt: Long,
) = timeFormatter.parseLocalDateOrNull(stored)
?: timeFormatter.parseInstant(operationAt).toLocalDateTime(TimeZone.UTC).date