Coverage Summary for Class: ApplyTransactionChangeUseCase (com.georgeci.moneysurfer.domain.usecase)

Class Method, % Branch, % Line, % Instruction, %
ApplyTransactionChangeUseCase 100% (4/4) 93.8% (15/16) 100% (14/14) 100% (192/192)
ApplyTransactionChangeUseCase$applyBalanceDelta$1
ApplyTransactionChangeUseCase$invoke$1
ApplyTransactionChangeUseCase$restore$1
Total 100% (4/4) 93.8% (15/16) 100% (14/14) 100% (192/192)


 package com.georgeci.moneysurfer.domain.usecase
 
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.model.calculateBalanceDelta
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 import com.georgeci.moneysurfer.domain.repositories.AccountRepository
 import com.georgeci.moneysurfer.domain.repositories.TransactionRepository
 import org.koin.core.annotation.Single
 
 /**
  * Single writer for any transaction change (create / update / delete / restore). Computes the
  * balance delta against [old] and applies it to the affected accounts, then writes the new tx row
  * (or tombstones it). Outbox enqueue happens inside the repository writes.
  *
  * Sequencing: the account-balance update happens first so a partial failure leaves the
  * cache reflecting the intent (will be reconciled by recalc if it diverges). The Room
  * write path is not wrapped in a single transaction in v1 — the project accepts this risk
  * elsewhere; see md/totatl_calc.md §4.
  *
  * - `old == null && new != null` → create
  * - `old != null && new != null` → update (any field, including status / accountId)
  * - `old != null && new == null` → delete (soft-delete locally — issue #346)
  * - `old == null && new == null` → no-op
  *
  * [restore] is the fourth shape, and the reason it is a named entry point rather than another
  * `invoke` combination: it takes an id, not a `Transaction`, because the row it revives is still
  * in the database and is the authority on its own fields.
  */
 @Single
 class ApplyTransactionChangeUseCase(
     private val transactionRepository: TransactionRepository,
     private val accountRepository: AccountRepository,
 ) {
 
     suspend operator fun invoke(old: Transaction?, new: Transaction?) {
         applyBalanceDelta(old, new)
         when {
             old == null && new != null -> transactionRepository.insert(new)
             old != null && new != null -> transactionRepository.update(new)
             old != null && new == null -> transactionRepository.delete(old.id)
             else -> Unit
         }
     }
 
     /**
      * Lifts the tombstone on [id] and puts its money back on the affected account, returning the
      * restored transaction — or `null` when there was nothing deleted under that id.
      *
      * The balance moves *after* the row, unlike the write paths above, because the delta is
      * computed from the restored row rather than from a copy the caller held: reading it back is
      * what guarantees the refund matches what actually came back. Nothing happens at all when the
      * repository reports no tombstone, so a second Undo cannot credit the account twice.
      */
     suspend fun restore(id: TransactionId): Transaction? {
         val restored = transactionRepository.restore(id) ?: return null
         applyBalanceDelta(old = null, new = restored)
         return restored
     }
 
     private suspend fun applyBalanceDelta(old: Transaction?, new: Transaction?) {
         for ((accountId, money) in calculateBalanceDelta(old, new)) {
             accountRepository.applyDelta(accountId, money)
         }
     }
 }