Coverage Summary for Class: ImportTransactionsUseCase (com.georgeci.moneysurfer.domain.csv)

Class Method, % Branch, % Line, % Instruction, %
ImportTransactionsUseCase 100% (11/11) 97.6% (41/42) 100% (67/67) 99.8% (798/800)
ImportTransactionsUseCase$Companion
ImportTransactionsUseCase$DecodedRow 100% (1/1) 100% (1/1) 100% (12/12)
ImportTransactionsUseCase$decodeRow$1
ImportTransactionsUseCase$importRecords$1
ImportTransactionsUseCase$importRow$1
ImportTransactionsUseCase$invoke$1
ImportTransactionsUseCase$invoke$2 100% (1/1) 100% (1/1) 100% (22/22)
ImportTransactionsUseCase$isDuplicate$1
ImportTransactionsUseCase$KnownIds 100% (4/4) 100% (16/16) 100% (30/30) 100% (136/136)
ImportTransactionsUseCase$loadKnownIds$1
ImportTransactionsUseCase$RowResult
ImportTransactionsUseCase$RowResult$Duplicate 100% (1/1) 100% (1/1) 100% (7/7)
ImportTransactionsUseCase$RowResult$Failed 100% (1/1) 100% (1/1) 100% (7/7)
ImportTransactionsUseCase$RowResult$Insert 100% (1/1) 100% (1/1) 100% (7/7)
ImportTransactionsUseCase$runImport$1
Total 100% (20/20) 98.3% (57/58) 100% (102/102) 99.8% (989/991)


 package com.georgeci.moneysurfer.domain.csv
 
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.model.Category
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 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.AccountRepository
 import com.georgeci.moneysurfer.domain.repositories.CategoryRepository
 import com.georgeci.moneysurfer.domain.repositories.TransactionRepository
 import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
 import com.georgeci.moneysurfer.domain.usecase.ApplyTransactionChangeUseCase
 import com.georgeci.moneysurfer.domain.usecase.CreateTransactionUseCase
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.IO
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.withContext
 import okio.BufferedSource
 import org.koin.core.annotation.Single
 
 /**
  * Merges transactions from a CSV produced by [ExportTransactionsUseCase] — by
  * this build or by any earlier one, see [TransactionCsvFormat] — into the local
  * database. Rows whose id already exists locally (or earlier in the
  * same file) are skipped as duplicates; rows that fail to parse, reference
  * unknown workspaces/accounts/categories, cross workspace boundaries, carry a
  * currency other than their account's, or form an inconsistent transfer pair
  * are collected in [CsvImportReport.errors] with their row number — one bad
  * row never aborts the rest of the file.
  *
  * Inserts go through [CreateTransactionUseCase] so cached account balances
  * stay consistent and sync outbox entries are enqueued. Caller owns [source]
  * and must close it.
  */
 @Single
 class ImportTransactionsUseCase(
     private val transactionRepository: TransactionRepository,
     private val accountRepository: AccountRepository,
     private val categoryRepository: CategoryRepository,
     private val workspaceRepository: WorkspaceRepository,
     private val createTransaction: CreateTransactionUseCase,
     private val applyTransactionChange: ApplyTransactionChangeUseCase,
 ) {
 
     suspend operator fun invoke(source: BufferedSource): Result<CsvImportReport> = try {
         Result.success(withContext(Dispatchers.IO) { runImport(source) })
     } catch (cancelled: CancellationException) {
         throw cancelled
     } catch (csvError: TransactionCsvError) {
         Result.failure(csvError)
     } catch (
         @Suppress("TooGenericExceptionCaught") t: Throwable,
     ) {
         Result.failure(TransactionCsvError.Unexpected(t))
     }
 
     private suspend fun runImport(source: BufferedSource): CsvImportReport {
         val records = Csv.parseRecords(readCapped(source))
         val headerRecord = records.firstOrNull() ?: throw TransactionCsvError.EmptyFile
         // The header row identifies the layout, so a backup written by an older
         // build still imports — its rows just carry fewer columns, and the
         // fields it never had decode to their "not recorded" defaults.
         val format = TransactionCsvFormat.forHeader(headerRecord.fields)
             ?: throw TransactionCsvError.HeaderMismatch
         return importRecords(records.drop(1), loadKnownIds(), format)
     }
 
     /**
      * Reads the whole file, but bounds the read first: `request(limit + 1)`
      * pulls from the source only until at least `limit + 1` bytes are buffered
      * (Okio fills in ~8 KiB segments, so it stops within one segment of the
      * threshold — never the whole file), and a `true` result means the file is
      * larger than the cap, so we reject without materialising it. This also
      * caps the giant single field an unclosed quote would otherwise produce —
      * it can never exceed the total limit.
      */
     private fun readCapped(source: BufferedSource): String {
         if (source.request(MAX_IMPORT_BYTES + 1)) {
             throw TransactionCsvError.FileTooLarge(MAX_IMPORT_BYTES)
         }
         return source.readUtf8()
     }
 
     // Decode-then-insert, in two passes: transfer pairing is a cross-row
     // property, so every row must be decoded before any insert decision.
     // Memory is bounded by the MAX_IMPORT_BYTES cap on the file itself.
     private suspend fun importRecords(
         records: List<CsvRecord>,
         known: KnownIds,
         format: TransactionCsvFormat,
     ): CsvImportReport {
         val rows = records.map { DecodedRow(it.rowNumber, decodeRow(it.fields, known, format)) }
         val unpaired = unpairedTransferIds(rows)
         var imported = 0
         var skipped = 0
         val errors = mutableListOf<CsvRowError>()
         for ((rowNumber, result) in rows) {
             when (result) {
                 is RowResult.Duplicate -> skipped++
                 is RowResult.Failed -> errors.add(CsvRowError(rowNumber, result.issue))
                 is RowResult.Insert -> {
                     val transferId = result.transaction.transferId
                     if (transferId != null && transferId in unpaired) {
                         errors.add(
                             CsvRowError(rowNumber, CsvRowIssue.UnpairedTransfer(transferId.value)),
                         )
                     } else {
                         importRow(result.transaction)
                         imported++
                     }
                 }
             }
         }
         return CsvImportReport(imported = imported, skippedDuplicates = skipped, errors = errors)
     }
 
     /**
      * Brings one importable row into the database — by lifting its tombstone if the id is still
      * held by a deleted row, and by inserting it otherwise.
      *
      * The tombstone branch exists because a delete no longer frees the id (issue #346): a plain
      * insert would hit the primary key and fail the whole import. Restoring is also what the user
      * asked for — importing a backup that contains a row you deleted is a request to have it
      * back — and it is the same row the export was taken from, so nothing of the CSV's content is
      * lost by preferring the stored copy.
      */
     private suspend fun importRow(transaction: Transaction) {
         if (applyTransactionChange.restore(transaction.id) != null) return
         createTransaction(transaction)
     }
 
     /**
      * Transfer ids whose legs do not form a consistent pair in this file:
      * exactly two rows (unique by transaction id), one EXPENSE and one INCOME,
      * on two different accounts. Duplicate rows count via their in-file copy,
      * so re-importing a full export where one leg already exists still pairs
      * up. Pending rows carrying an unpaired id are rejected — importing a lone
      * leg would show a half-transfer in the UI and skew one balance (#160).
      */
     private fun unpairedTransferIds(rows: List<DecodedRow>): Set<TransferId> {
         val legsByTransfer = rows
             .mapNotNull { row ->
                 when (val result = row.result) {
                     is RowResult.Duplicate -> result.transaction
                     is RowResult.Insert -> result.transaction
                     is RowResult.Failed -> null
                 }
             }
             .distinctBy { it.id }
             .mapNotNull { transaction -> transaction.transferId?.let { it to transaction } }
             .groupBy({ it.first }, { it.second })
         return legsByTransfer.filterValues { !it.isConsistentTransferPair() }.keys
     }
 
     private fun List<Transaction>.isConsistentTransferPair(): Boolean =
         size == 2 &&
             mapTo(mutableSetOf()) { it.type } ==
             setOf(TransactionType.EXPENSE, TransactionType.INCOME) &&
             this[0].accountId != this[1].accountId
 
     private suspend fun decodeRow(
         fields: List<String>,
         known: KnownIds,
         format: TransactionCsvFormat,
     ): RowResult =
         when (val decoded = TransactionCsvCodec.decode(fields, format)) {
             is TransactionCsvDecodeResult.Rejected -> RowResult.Failed(decoded.issue)
             is TransactionCsvDecodeResult.Decoded -> {
                 val transaction = decoded.transaction
                 val referenceIssue = known.referenceIssue(transaction)
                 when {
                     isDuplicate(transaction.id, known) -> RowResult.Duplicate(transaction)
                     referenceIssue != null -> RowResult.Failed(referenceIssue)
                     else -> {
                         known.importedIds.add(transaction.id)
                         RowResult.Insert(transaction)
                     }
                 }
             }
         }
 
     /**
      * Duplicate = already inserted from this file, or already in the database.
      * The DB side is an indexed point lookup per row instead of prefetching
      * every transaction id — transactions is the one table that can be large.
      */
     private suspend fun isDuplicate(id: TransactionId, known: KnownIds): Boolean =
         id in known.importedIds || transactionRepository.getById(id) != null
 
     private data class DecodedRow(val rowNumber: Int, val result: RowResult)
 
     private sealed interface RowResult {
         data class Duplicate(val transaction: Transaction) : RowResult
         data class Failed(val issue: CsvRowIssue) : RowResult
         data class Insert(val transaction: Transaction) : RowResult
     }
 
     private class KnownIds(
         val workspaceIds: Set<WorkspaceId>,
         val accountsById: Map<AccountId, Account>,
         val categoriesById: Map<CategoryId, Category>,
     ) {
         /** Ids accepted for insert from this file (also catches in-file duplicates). */
         val importedIds: MutableSet<TransactionId> = mutableSetOf()
 
         /**
          * First reference problem of the row, or null if it is importable.
          * Beyond bare existence, the account and category must belong to the
          * row's `workspace_id` and the row's currency must equal the account's
          * — a crafted CSV could otherwise attach a transaction of workspace A
          * to an account of workspace B (or in the wrong currency) and silently
          * diverge cached balances and per-workspace reports (#160).
          */
         fun referenceIssue(transaction: Transaction): CsvRowIssue? = when {
             transaction.workspaceId !in workspaceIds ->
                 CsvRowIssue.UnknownWorkspace(transaction.workspaceId.value)
             else -> accountIssue(transaction) ?: categoryIssue(transaction)
         }
 
         private fun accountIssue(transaction: Transaction): CsvRowIssue? {
             val account = accountsById[transaction.accountId]
                 ?: return CsvRowIssue.UnknownAccount(transaction.accountId.value)
             return when {
                 account.workspaceId != transaction.workspaceId ->
                     CsvRowIssue.AccountWorkspaceMismatch(
                         accountId = transaction.accountId.value,
                         workspaceId = transaction.workspaceId.value,
                     )
                 transaction.currencyCode != account.currencyCode ->
                     CsvRowIssue.CurrencyMismatch(
                         currency = transaction.currencyCode.value,
                         accountCurrency = account.currencyCode.value,
                     )
                 else -> null
             }
         }
 
         private fun categoryIssue(transaction: Transaction): CsvRowIssue? {
             val categoryId = transaction.categoryId ?: return null
             val category = categoriesById[categoryId]
                 ?: return CsvRowIssue.UnknownCategory(categoryId.value)
             return when {
                 category.workspaceId != transaction.workspaceId ->
                     CsvRowIssue.CategoryWorkspaceMismatch(
                         categoryId = categoryId.value,
                         workspaceId = transaction.workspaceId.value,
                     )
                 else -> null
             }
         }
     }
 
     // Workspaces, accounts, and categories are small tables; prefetching them
     // once beats a per-row lookup for each of the three references.
     private suspend fun loadKnownIds(): KnownIds = KnownIds(
         workspaceIds = workspaceRepository.getAll().first().mapTo(mutableSetOf()) { it.id },
         accountsById = accountRepository.getAll().first().associateBy { it.id },
         categoriesById = categoryRepository.getAll().first().associateBy { it.id },
     )
 
     private companion object {
         /**
          * Hard cap on the CSV we read into memory. Import buffers the whole file
          * (see [runImport]); without a bound a multi-GB pick — accidental or a
          * hostile "backup" shared to the user — would OOM the app. A real
          * transactions export runs a few hundred bytes per row, so 16 MiB still
          * covers tens of thousands of transactions.
          */
         private const val MAX_IMPORT_BYTES = 16L * 1024 * 1024
     }
 }