Implement expense interactors (#2) #11

Merged
admin merged 3 commits from feat/2-implement-expense-interactors into main 2026-06-28 09:58:45 +00:00
8 changed files with 160 additions and 1 deletions
@@ -1,7 +1,9 @@
package dev.achmad.ledgerr.data.local.mapper
import dev.achmad.ledgerr.data.local.dao.ExpenseWithCategoryRow
import dev.achmad.ledgerr.data.local.entity.ExpenseEntity
import dev.achmad.ledgerr.domain.expense.model.Expense
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
fun ExpenseEntity.toModel(): Expense = Expense(
id = id,
@@ -22,3 +24,8 @@ fun Expense.toEntity(): ExpenseEntity = ExpenseEntity(
recurringExpenseId = recurringExpenseId,
createdAt = createdAt,
)
fun ExpenseWithCategoryRow.toModel(): ExpenseWithCategory = ExpenseWithCategory(
expense = expense.toModel(),
category = category.toModel(),
)
@@ -4,7 +4,12 @@ import dev.achmad.ledgerr.domain.category.interactor.DeleteCategory
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
import dev.achmad.ledgerr.domain.expense.interactor.DeleteExpense
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenseSummary
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.interactor.InsertExpenses
import dev.achmad.ledgerr.domain.expense.interactor.ReassignExpenseCategory
import dev.achmad.ledgerr.domain.expense.interactor.UpsertExpense
import dev.achmad.ledgerr.domain.recurring.interactor.DeleteRecurringExpense
import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses
import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
@@ -17,7 +22,12 @@ val domainModule = module {
factory { DeleteCategory(get(), get(), get()) }
factory { SeedDefaultCategories(get()) }
factory { GetExpenses(get(), get()) }
factory { UpsertExpense(get()) }
factory { InsertExpenses(get()) }
factory { DeleteExpense(get()) }
factory { ReassignExpenseCategory(get()) }
factory { GetExpenseSummary(get(), get()) }
factory { GetRecurringExpenses(get()) }
factory { UpsertRecurringExpense(get()) }
@@ -0,0 +1,11 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
class DeleteExpense(
private val dao: ExpenseDao,
) {
suspend fun await(id: Long) {
dao.deleteById(id)
}
}
@@ -0,0 +1,36 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
import dev.achmad.ledgerr.data.local.mapper.toModel
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.ExpenseSummary
class GetExpenseSummary(
private val dao: ExpenseDao,
private val categoryDao: CategoryDao,
) {
suspend fun await(range: DateRange): ExpenseSummary {
val expenses = dao.getByDateRange(
startDay = range.start.toEpochDay(),
endDay = range.end.toEpochDay(),
)
val totalAmount = expenses.sumOf { it.amount }
val categoryMap = categoryDao.getAll().associate { it.id to it.toModel() }
// ForeignKey.RESTRICT keeps categories intact, but be defensive: if a
// category was deleted out-of-band, drop its group from byCategory
// rather than crashing. totalAmount still includes the orphan rows.
val byCategory = expenses
.groupBy { it.categoryId }
.mapNotNull { (categoryId, group) ->
val category = categoryMap[categoryId] ?: return@mapNotNull null
category to group.sumOf { it.amount }
}
.sortedByDescending { it.second }
return ExpenseSummary(
totalAmount = totalAmount,
byCategory = byCategory,
period = range,
)
}
}
@@ -0,0 +1,61 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
import dev.achmad.ledgerr.data.local.dao.ExpenseWithCategoryRow
import dev.achmad.ledgerr.data.local.mapper.toModel
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetExpenses(
private val dao: ExpenseDao,
private val categoryDao: CategoryDao,
) {
fun subscribeAll(): Flow<List<ExpenseWithCategory>> =
dao.subscribeAll().map { rows -> rows.map { it.toModel() } }
fun subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>> =
dao.subscribeByDateRange(
startDay = range.start.toEpochDay(),
endDay = range.end.toEpochDay(),
).map { rows -> rows.map { it.toModel() } }
suspend fun awaitOne(id: Long): ExpenseWithCategory? {
val expense = dao.getById(id) ?: return null
val category = categoryDao.getById(expense.categoryId) ?: return null
return ExpenseWithCategoryRow(
Outdated
Review

This manually inlines what the new ExpenseWithCategoryRow.toModel() mapper in ExpenseMapper.kt already does. The helper is added in this same PR and used by subscribeAll (line 16) and subscribeByDateRange (line 22), but not here. Pick one for consistency:

  • construct ExpenseWithCategoryRow(expense, category).toModel() here, or
  • remove the new mapper and inline .map { it.expense.toModel() ... } in the two subscribers.
This manually inlines what the new `ExpenseWithCategoryRow.toModel()` mapper in `ExpenseMapper.kt` already does. The helper is added in this same PR and used by `subscribeAll` (line 16) and `subscribeByDateRange` (line 22), but not here. Pick one for consistency: - construct `ExpenseWithCategoryRow(expense, category).toModel()` here, or - remove the new mapper and inline `.map { it.expense.toModel() ... }` in the two subscribers.
expense = expense,
category = category,
).toModel()
}
suspend fun awaitAll(
query: String = "",
range: DateRange? = null,
): List<ExpenseWithCategory> {
val categoryMap = categoryDao.getAll().associate { it.id to it.toModel() }
val expenses = dao.search(
rangeStart = range?.start?.toEpochDay(),
rangeEnd = range?.end?.toEpochDay(),
)
return expenses
.filter { entity ->
if (query.isBlank()) return@filter true
val note = entity.note.orEmpty()
val amountStr = entity.amount.toString()
val categoryName = categoryMap[entity.categoryId]?.name.orEmpty()
note.contains(query, ignoreCase = true) ||
amountStr.contains(query) ||
categoryName.contains(query, ignoreCase = true)
}
.mapNotNull { entity ->
val category = categoryMap[entity.categoryId] ?: return@mapNotNull null
ExpenseWithCategory(
expense = entity.toModel(),
category = category,
)
}
}
}
@@ -0,0 +1,17 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
import dev.achmad.ledgerr.data.local.mapper.toEntity
import dev.achmad.ledgerr.domain.expense.model.Expense
class InsertExpenses(
private val dao: ExpenseDao,
) {
suspend fun awaitAll(expenses: List<Expense>) {
if (expenses.isEmpty()) return
require(expenses.all { it.id == 0L }) {
Outdated
Review

docs/03-function-todos.md:33 says "expense.id must be 0 for all items", but this isn't enforced. A non-zero id would hit the plain @Insert on dao.insertAll (no OnConflictStrategy.REPLACE) and throw SQLiteConstraintException at runtime. Either:

  • require(expenses.all { it.id == 0L }) (fail fast with a clear message), or
  • .map { it.copy(id = 0).toEntity() } (silently normalize — less safe, but matches the bulk-import use case where ids are caller-provided).
`docs/03-function-todos.md:33` says "expense.id must be 0 for all items", but this isn't enforced. A non-zero id would hit the plain `@Insert` on `dao.insertAll` (no `OnConflictStrategy.REPLACE`) and throw `SQLiteConstraintException` at runtime. Either: - `require(expenses.all { it.id == 0L })` (fail fast with a clear message), or - `.map { it.copy(id = 0).toEntity() }` (silently normalize — less safe, but matches the bulk-import use case where ids are caller-provided).
"InsertExpenses.awaitAll requires all Expense.id == 0L (got ${expenses.filter { it.id != 0L }.map { it.id }})"
}
dao.insertAll(expenses.map { it.toEntity() })
}
}
@@ -0,0 +1,17 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
import dev.achmad.ledgerr.data.local.mapper.toEntity
import dev.achmad.ledgerr.domain.expense.model.Expense
class UpsertExpense(
private val dao: ExpenseDao,
) {
suspend fun await(expense: Expense): Long {
if (expense.id == 0L) {
return dao.insert(expense.toEntity())
}
dao.update(expense.toEntity())
return expense.id
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ One-shot search. If `query` is blank and `range` is null, returns all expenses (
## expense / UpsertExpense
### `await(expense: Expense): Long`
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Uses Room `@Upsert`.
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Routes through separate `dao.insert` / `dao.update` (the DAO does not expose a `@Upsert` method).
---