Coverage Summary for Class: SpendAnalyticsRepositoryImpl (com.georgeci.moneysurfer.data.repository)

Class Method, % Branch, % Line, % Instruction, %
SpendAnalyticsRepositoryImpl 100% (6/6) 100% (33/33) 100% (86/86)
SpendAnalyticsRepositoryImpl$byCategory$$inlined$map$1 0% (0/2)
SpendAnalyticsRepositoryImpl$byCategory$$inlined$map$1$1
SpendAnalyticsRepositoryImpl$byCategory$$inlined$map$1$2 0% (0/1)
SpendAnalyticsRepositoryImpl$byCategory$$inlined$map$1$2$1
SpendAnalyticsRepositoryImpl$daily$$inlined$map$1 0% (0/2)
SpendAnalyticsRepositoryImpl$daily$$inlined$map$1$1
SpendAnalyticsRepositoryImpl$daily$$inlined$map$1$2 0% (0/1)
SpendAnalyticsRepositoryImpl$daily$$inlined$map$1$2$1
SpendAnalyticsRepositoryImpl$excludedByCurrency$$inlined$map$1 0% (0/2)
SpendAnalyticsRepositoryImpl$excludedByCurrency$$inlined$map$1$1
SpendAnalyticsRepositoryImpl$excludedByCurrency$$inlined$map$1$2 0% (0/1)
SpendAnalyticsRepositoryImpl$excludedByCurrency$$inlined$map$1$2$1
SpendAnalyticsRepositoryImpl$netByMonth$$inlined$map$1 0% (0/2)
SpendAnalyticsRepositoryImpl$netByMonth$$inlined$map$1$1
SpendAnalyticsRepositoryImpl$netByMonth$$inlined$map$1$2 0% (0/1)
SpendAnalyticsRepositoryImpl$netByMonth$$inlined$map$1$2$1
SpendAnalyticsRepositoryImpl$topMerchants$$inlined$map$1 0% (0/2)
SpendAnalyticsRepositoryImpl$topMerchants$$inlined$map$1$1
SpendAnalyticsRepositoryImpl$topMerchants$$inlined$map$1$2 0% (0/1)
SpendAnalyticsRepositoryImpl$topMerchants$$inlined$map$1$2$1
Total 28.6% (6/21) 100% (33/33) 100% (86/86)


 package com.georgeci.moneysurfer.data.repository
 
 import com.georgeci.moneysurfer.data.db.dao.TransactionDao
 import com.georgeci.moneysurfer.data.db.entity.CategorySpendSliceEntity
 import com.georgeci.moneysurfer.data.db.entity.CurrencySpendEntity
 import com.georgeci.moneysurfer.data.db.entity.DailySpendEntity
 import com.georgeci.moneysurfer.data.db.entity.MerchantSpendEntity
 import com.georgeci.moneysurfer.data.db.entity.MonthlyTypeTotalEntity
 import com.georgeci.moneysurfer.domain.model.CategorySpendSlice
 import com.georgeci.moneysurfer.domain.model.CurrencyTotal
 import com.georgeci.moneysurfer.domain.model.DailySpendPoint
 import com.georgeci.moneysurfer.domain.model.MerchantSpend
 import com.georgeci.moneysurfer.domain.model.MonthlyNet
 import com.georgeci.moneysurfer.domain.model.SpendScope
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.repositories.SpendAnalyticsRepository
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.conflate
 import kotlinx.coroutines.flow.map
 import kotlinx.datetime.DateTimeUnit
 import kotlinx.datetime.LocalDate
 import kotlinx.datetime.YearMonth
 import kotlinx.datetime.plus
 import org.koin.core.annotation.Single
 
 /**
  * [SpendAnalyticsRepository] over the aggregate queries in [TransactionDao].
  *
  * Injects the DAO directly, as `CategorySpendRepositoryImpl` does — there is no transaction-shaped
  * intermediate to reuse here, and routing a `GROUP BY` through a repository that returns whole
  * rows is what this class exists to avoid.
  *
  * Every flow is [conflate]d: Room's invalidation tracker fires per write, and
  * `TransactionSyncPlugin.applyDoc` writes a pulled batch row by row, so an un-conflated aggregate
  * would recompute once per row for a screen that can only draw the last result anyway.
  */
 @Single(binds = [SpendAnalyticsRepository::class])
 class SpendAnalyticsRepositoryImpl(
     private val dao: TransactionDao,
 ) : SpendAnalyticsRepository {
 
     override fun byCategory(scope: SpendScope): Flow<List<CategorySpendSlice>> =
         dao.getSpendByCategory(
             workspaceId = scope.workspaceId.value,
             baseCurrency = scope.baseCurrency.value,
             fromDate = scope.fromDate(),
             toDateExclusive = scope.toDateExclusive(),
         ).map { rows -> rows.map(CategorySpendSliceEntity::toDomain) }.conflate()
 
     override fun netByMonth(scope: SpendScope): Flow<List<MonthlyNet>> =
         dao.getNetTotalsByMonth(
             workspaceId = scope.workspaceId.value,
             baseCurrency = scope.baseCurrency.value,
             fromDate = scope.fromDate(),
             toDateExclusive = scope.toDateExclusive(),
         ).map { rows -> rows.toMonthlyNets() }.conflate()
 
     override fun daily(scope: SpendScope): Flow<List<DailySpendPoint>> =
         dao.getDailySpend(
             workspaceId = scope.workspaceId.value,
             baseCurrency = scope.baseCurrency.value,
             fromDate = scope.fromDate(),
             toDateExclusive = scope.toDateExclusive(),
         ).map { rows -> rows.mapNotNull(DailySpendEntity::toDomain) }.conflate()
 
     override fun topMerchants(scope: SpendScope, limit: Int): Flow<List<MerchantSpend>> =
         dao.getTopMerchants(
             workspaceId = scope.workspaceId.value,
             baseCurrency = scope.baseCurrency.value,
             fromDate = scope.fromDate(),
             toDateExclusive = scope.toDateExclusive(),
             limit = limit,
         ).map { rows -> rows.map(MerchantSpendEntity::toDomain) }.conflate()
 
     override fun excludedByCurrency(scope: SpendScope): Flow<List<CurrencyTotal>> =
         dao.getSpendByExcludedCurrency(
             workspaceId = scope.workspaceId.value,
             baseCurrency = scope.baseCurrency.value,
             fromDate = scope.fromDate(),
             toDateExclusive = scope.toDateExclusive(),
         ).map { rows -> rows.map(CurrencySpendEntity::toDomain) }.conflate()
 }
 
 private fun SpendScope.fromDate(): String? = window.from?.toString()
 
 /**
  * The window is closed at the top ([com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow]
  * models what the transactions list needs), the predicate is half-open. Converting here rather
  * than comparing with `<=` keeps the SQL identical to the one budgets and the aligned category
  * trend use, so the three can only agree.
  */
 private fun SpendScope.toDateExclusive(): String? =
     window.to?.plus(1, DateTimeUnit.DAY)?.toString()
 
 private fun CategorySpendSliceEntity.toDomain(): CategorySpendSlice = CategorySpendSlice(
     categoryId = categoryId?.let(::CategoryId),
     total = Money.fromMinor(totalMinor),
     transactionCount = transactionCount,
 )
 
 private fun MerchantSpendEntity.toDomain(): MerchantSpend = MerchantSpend(
     merchant = merchant,
     total = Money.fromMinor(totalMinor),
     transactionCount = transactionCount,
 )
 
 private fun CurrencySpendEntity.toDomain(): CurrencyTotal = CurrencyTotal(
     currencyCode = CurrencyCode(currencyCode),
     amount = Money.fromMinor(totalMinor),
 )
 
 /**
  * Null for a row whose date will not parse — dropping one unreadable day beats failing the whole
  * screen, the same defence [CategorySpendRepositoryImpl] applies to its months.
  *
  * Kept as a second line rather than a live path: the query's `operationDate = date(operationDate)`
  * term already admits nothing but a canonical `YYYY-MM-DD`, which [LocalDate.parse] always accepts,
  * so nothing reaching here can fail. That is why coverage reports the null branch as never taken —
  * it is unreachable by construction, and only stays because `operationDate` is a plain text column
  * one predicate change away from letting something else through.
  */
 private fun DailySpendEntity.toDomain(): DailySpendPoint? {
     val parsed = runCatching { LocalDate.parse(operationDate) }.getOrNull() ?: return null
     return DailySpendPoint(date = parsed, total = Money.fromMinor(totalMinor))
 }
 
 /**
  * Folds the `(month, type)` cells into one row per month, oldest first.
  *
  * A month arrives as one row per type it booked, so the side it did not book folds to zero rather
  * than dropping the column from the chart. Months keep the order SQLite returned them in, which
  * `ORDER BY month ASC` already makes ascending.
  *
  * The unparseable-month branch is the same unreachable-by-construction guard [DailySpendEntity]
  * carries: `substr` over a canonical date cannot produce anything [YearMonth.parse] refuses.
  */
 private fun List<MonthlyTypeTotalEntity>.toMonthlyNets(): List<MonthlyNet> =
     groupBy { it.month }.mapNotNull { (month, rows) ->
         val parsed = runCatching { YearMonth.parse(month) }.getOrNull() ?: return@mapNotNull null
         MonthlyNet(
             month = parsed,
             income = rows.totalOf(TransactionType.INCOME),
             expense = rows.totalOf(TransactionType.EXPENSE),
         )
     }
 
 private fun List<MonthlyTypeTotalEntity>.totalOf(type: TransactionType): Money =
     Money.fromMinor(filter { it.type == type.name }.sumOf { it.totalMinor })