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

Class Method, % Branch, % Line, % Instruction, %
SeedDefaultsUseCase 100% (4/4) 84.6% (22/26) 100% (43/43) 98.7% (392/397)
SeedDefaultsUseCase$Companion
SeedDefaultsUseCase$invoke$1
SeedDefaultsUseCase$seedCashAccountIfMissing$1
Total 100% (4/4) 84.6% (22/26) 100% (43/43) 98.7% (392/397)


 package com.georgeci.moneysurfer.domain.usecase
 
 import arrow.core.Either
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.domain.auth.SessionPointers
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.AccountType
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
 import com.georgeci.moneysurfer.domain.repositories.AccountRepository
 import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
 import kotlinx.coroutines.flow.first
 import org.koin.core.annotation.Single
 
 /**
  * Bootstrap for an empty install: creates a "Personal" workspace (which seeds the default
  * category set via [CreateWorkspaceUseCase]) and, when asked for, a single "Cash" account.
  *
  * Idempotency contract — the use case is safe to call on every launch:
  *  - If no workspace is pinned, create one + seed Cash.
  *  - If a workspace is already pinned, *repair* missing defaults — i.e. ensure the pinned
  *    workspace has a Cash account. This recovers from a previous run that pinned the workspace
  *    but died before inserting the account, instead of stranding the user on a Dashboard with
  *    no accounts forever (Copilot review feedback on PR #91).
  *  - Existing Cash accounts are never duplicated.
  *
  * `seedCashAccount = false` skips both the seed and the repair: the offline first launch lets
  * the user create their first account explicitly (onboarding → account creation), so an
  * auto-inserted "Cash" row would just be noise. `AppLaunchViewModel` covers the crash window
  * by routing back to the first-run account screen while the workspace has no accounts.
  *
  * Insert failures are caught and logged so a transient repository error doesn't block the
  * caller (e.g. `AppLaunchViewModel`) from making a navigation decision.
  *
  * The use case is host-agnostic — the caller supplies the seed [CurrencyCode] so platform
  * locale lookup stays out of the domain layer.
  */
 @Single
 class SeedDefaultsUseCase(
     private val createWorkspace: CreateWorkspaceUseCase,
     private val accountRepository: AccountRepository,
     private val workspaceRepository: WorkspaceRepository,
     private val session: SessionPointers,
     private val getCurrentTime: GetCurrentTimeUseCase,
 ) {
     private val log = Logger.withTag(TAG)
 
     suspend operator fun invoke(currency: CurrencyCode, seedCashAccount: Boolean = true) {
         val pinned = session.currentWorkspaceId.first()
         if (pinned != null) {
             if (!seedCashAccount) return
             // Repair path: previous run may have pinned the workspace but died before the Cash
             // insert. Use the workspace's own baseCurrency for repair so we don't overwrite the
             // user's currency choice with a freshly derived locale value.
             val workspaceCurrency = runCatching { workspaceRepository.getById(pinned)?.baseCurrency }
                 .getOrNull() ?: currency
             seedCashAccountIfMissing(pinned, workspaceCurrency)
             return
         }
         log.i { "[start] currency=${currency.value}" }
         val newId = createWorkspace(
             CreateWorkspaceUseCase.Params(
                 name = DEFAULT_WORKSPACE_NAME,
                 description = "",
                 baseCurrency = currency,
             ),
         ).fold(
             ifLeft = { err ->
                 log.w { "[abort] CreateWorkspace failed: $err" }
                 return
             },
             ifRight = { it },
         )
         if (seedCashAccount) seedCashAccountIfMissing(newId, currency)
     }
 
     private suspend fun seedCashAccountIfMissing(workspaceId: WorkspaceId, currency: CurrencyCode) {
         val existing = Either.catch { accountRepository.getByWorkspaceId(workspaceId).first() }
             .getOrNull()
             .orEmpty()
         val alreadyHasCash = existing.any { it.name == DEFAULT_ACCOUNT_NAME && it.type == AccountType.CASH }
         if (alreadyHasCash) {
             log.d { "[skip] Cash account already present wid=${workspaceId.value}" }
             return
         }
         Either.catch {
             accountRepository.insert(
                 Account(
                     id = AccountId.uuid(),
                     workspaceId = workspaceId,
                     name = DEFAULT_ACCOUNT_NAME,
                     type = AccountType.CASH,
                     currencyCode = currency,
                     balance = Money.zero(),
                     updatedAt = getCurrentTime(),
                 ),
             )
         }
             .onLeft { log.w(it) { "[insert] Cash account failed wid=${workspaceId.value}" } }
             .onRight { log.i { "[done] seeded Cash account wid=${workspaceId.value}" } }
     }
 
     private companion object {
         const val TAG = "SeedDefaults"
         const val DEFAULT_WORKSPACE_NAME = "Personal"
         const val DEFAULT_ACCOUNT_NAME = "Cash"
     }
 }