Merge pull request 'Implement expense interactors (#2)' (#11) from feat/2-implement-expense-interactors into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
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(
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 }) {
"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).
---