Implement expense interactors (#2) #11
@@ -1,7 +1,9 @@
|
|||||||
package dev.achmad.ledgerr.data.local.mapper
|
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.data.local.entity.ExpenseEntity
|
||||||
import dev.achmad.ledgerr.domain.expense.model.Expense
|
import dev.achmad.ledgerr.domain.expense.model.Expense
|
||||||
|
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
|
||||||
|
|
||||||
fun ExpenseEntity.toModel(): Expense = Expense(
|
fun ExpenseEntity.toModel(): Expense = Expense(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -22,3 +24,8 @@ fun Expense.toEntity(): ExpenseEntity = ExpenseEntity(
|
|||||||
recurringExpenseId = recurringExpenseId,
|
recurringExpenseId = recurringExpenseId,
|
||||||
createdAt = createdAt,
|
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.GetCategories
|
||||||
import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
|
import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
|
||||||
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
|
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.ReassignExpenseCategory
|
||||||
|
import dev.achmad.ledgerr.domain.expense.interactor.UpsertExpense
|
||||||
import dev.achmad.ledgerr.domain.recurring.interactor.DeleteRecurringExpense
|
import dev.achmad.ledgerr.domain.recurring.interactor.DeleteRecurringExpense
|
||||||
import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses
|
import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses
|
||||||
import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
|
import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
|
||||||
@@ -17,7 +22,12 @@ val domainModule = module {
|
|||||||
factory { DeleteCategory(get(), get(), get()) }
|
factory { DeleteCategory(get(), get(), get()) }
|
||||||
factory { SeedDefaultCategories(get()) }
|
factory { SeedDefaultCategories(get()) }
|
||||||
|
|
||||||
|
factory { GetExpenses(get(), get()) }
|
||||||
|
factory { UpsertExpense(get()) }
|
||||||
|
factory { InsertExpenses(get()) }
|
||||||
|
factory { DeleteExpense(get()) }
|
||||||
factory { ReassignExpenseCategory(get()) }
|
factory { ReassignExpenseCategory(get()) }
|
||||||
|
factory { GetExpenseSummary(get(), get()) }
|
||||||
|
|
||||||
factory { GetRecurringExpenses(get()) }
|
factory { GetRecurringExpenses(get()) }
|
||||||
factory { UpsertRecurringExpense(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 }) {
|
||||||
|
admin
commented
`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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ One-shot search. If `query` is blank and `range` is null, returns all expenses (
|
|||||||
## expense / UpsertExpense
|
## expense / UpsertExpense
|
||||||
|
|
||||||
### `await(expense: Expense): Long`
|
### `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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user
This manually inlines what the new
ExpenseWithCategoryRow.toModel()mapper inExpenseMapper.ktalready does. The helper is added in this same PR and used bysubscribeAll(line 16) andsubscribeByDateRange(line 22), but not here. Pick one for consistency:ExpenseWithCategoryRow(expense, category).toModel()here, or.map { it.expense.toModel() ... }in the two subscribers.