From 46f882b3c3bbd038935e9c8c962f13f250287672 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:33:39 +0700 Subject: [PATCH 1/2] feat(#2): implement expense interactors --- .../data/local/mapper/ExpenseMapper.kt | 7 +++ .../dev/achmad/ledgerr/di/DomainModule.kt | 10 ++++ .../expense/interactor/DeleteExpense.kt | 11 ++++ .../expense/interactor/GetExpenseSummary.kt | 33 ++++++++++ .../domain/expense/interactor/GetExpenses.kt | 60 +++++++++++++++++++ .../expense/interactor/InsertExpenses.kt | 14 +++++ .../expense/interactor/UpsertExpense.kt | 17 ++++++ 7 files changed, 152 insertions(+) create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/DeleteExpense.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/UpsertExpense.kt diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt index 59cae18..cb98f0e 100644 --- a/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt @@ -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(), +) diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt index 56481b8..756a20a 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt @@ -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 org.koin.dsl.module val domainModule = module { @@ -13,5 +18,10 @@ 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()) } } diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/DeleteExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/DeleteExpense.kt new file mode 100644 index 0000000..7e6a648 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/DeleteExpense.kt @@ -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) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt new file mode 100644 index 0000000..fb22cd7 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt @@ -0,0 +1,33 @@ +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() } + 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, + ) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt new file mode 100644 index 0000000..0781b5f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt @@ -0,0 +1,60 @@ +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.ExpenseWithCategory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetExpenses( + private val dao: ExpenseDao, + private val categoryDao: CategoryDao, +) { + fun subscribeAll(): Flow> = + dao.subscribeAll().map { rows -> rows.map { it.toModel() } } + + fun subscribeByDateRange(range: DateRange): Flow> = + 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 ExpenseWithCategory( + expense = expense.toModel(), + category = category.toModel(), + ) + } + + suspend fun awaitAll( + query: String = "", + range: DateRange? = null, + ): List { + 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, ignoreCase = true) || + categoryName.contains(query, ignoreCase = true) + } + .mapNotNull { entity -> + val category = categoryMap[entity.categoryId] ?: return@mapNotNull null + ExpenseWithCategory( + expense = entity.toModel(), + category = category, + ) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt new file mode 100644 index 0000000..3c82eaa --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt @@ -0,0 +1,14 @@ +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) { + if (expenses.isEmpty()) return + dao.insertAll(expenses.map { it.toEntity() }) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/UpsertExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/UpsertExpense.kt new file mode 100644 index 0000000..dfa22a7 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/UpsertExpense.kt @@ -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 + } +} From 63bfe2a6b5eb15dc8067efa878a13a5c4c791533 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:52:42 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(#2):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20use=20row=20mapper,=20require=20id=3D=3D0,=20drop?= =?UTF-8?q?=20ignoreCase=20on=20amount,=20doc=20drift,=20orphan-category?= =?UTF-8?q?=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/expense/interactor/GetExpenseSummary.kt | 3 +++ .../ledgerr/domain/expense/interactor/GetExpenses.kt | 11 ++++++----- .../domain/expense/interactor/InsertExpenses.kt | 3 +++ docs/03-function-todos.md | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt index fb22cd7..d502e3f 100644 --- a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenseSummary.kt @@ -17,6 +17,9 @@ class GetExpenseSummary( ) 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) -> diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt index 0781b5f..02a1a5e 100644 --- a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/GetExpenses.kt @@ -2,6 +2,7 @@ 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 @@ -24,10 +25,10 @@ class GetExpenses( suspend fun awaitOne(id: Long): ExpenseWithCategory? { val expense = dao.getById(id) ?: return null val category = categoryDao.getById(expense.categoryId) ?: return null - return ExpenseWithCategory( - expense = expense.toModel(), - category = category.toModel(), - ) + return ExpenseWithCategoryRow( + expense = expense, + category = category, + ).toModel() } suspend fun awaitAll( @@ -46,7 +47,7 @@ class GetExpenses( val amountStr = entity.amount.toString() val categoryName = categoryMap[entity.categoryId]?.name.orEmpty() note.contains(query, ignoreCase = true) || - amountStr.contains(query, ignoreCase = true) || + amountStr.contains(query) || categoryName.contains(query, ignoreCase = true) } .mapNotNull { entity -> diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt index 3c82eaa..3f7a9c5 100644 --- a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/InsertExpenses.kt @@ -9,6 +9,9 @@ class InsertExpenses( ) { suspend fun awaitAll(expenses: List) { 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() }) } } diff --git a/docs/03-function-todos.md b/docs/03-function-todos.md index 43c21a2..0ee9b98 100644 --- a/docs/03-function-todos.md +++ b/docs/03-function-todos.md @@ -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). ---