Overview
ExpensesRepository handles all expense-related operations including creating expenses with splits, updating existing expenses, and tracking expenses across groups.
Interface: backend/ExpensesRepository.kt:15
Implementation: backend/SupabaseExpensesRepository.kt:21
Interface Definition
interface ExpensesRepository {
suspend fun listExpenses(): List<Expense>
suspend fun listExpensesByGroup(groupId: String): List<Expense>
suspend fun listGroupExpenses(groupId: String): List<GroupExpense>
suspend fun createExpense(
groupId: String,
description: String,
amountCents: Long,
splitMethod: String
): Expense
suspend fun createExpenseWithSplits(
groupId: String,
description: String,
amountCents: Long,
currency: String,
splitMethod: String,
splits: List<ExpenseSplit>
): GroupExpense
suspend fun getExpenseById(expenseId: String): Expense?
suspend fun getGroupExpenseById(expenseId: String): GroupExpense?
suspend fun updateExpense(
expenseId: String,
groupId: String,
description: String,
amountCents: Long,
splitMethod: String,
currency: String
): Expense
suspend fun updateExpenseSplits(expenseId: String, splits: List<ExpenseSplit>)
suspend fun deleteExpense(expenseId: String)
fun observeGroupExpenses(groupId: String): Flow<List<GroupExpense>>
fun observeAllGroupExpenses(): Flow<List<GroupExpense>>
suspend fun refreshGroupExpenses(groupId: String)
suspend fun refreshAllExpenses()
}
Implementation
SupabaseExpensesRepository
@Singleton
class SupabaseExpensesRepository @Inject constructor(
private val supabaseClient: SupabaseClient,
private val authRepository: AuthRepository
) : ExpensesRepository {
private val _expenses = MutableStateFlow<Map<String, List<GroupExpense>>>(emptyMap())
}
The repository caches expenses by group ID for efficient reactive updates.
Basic Expense Methods
listExpenses
Fetches all expenses from the database.
suspend fun listExpenses(): List<Expense>
Returns: List<Expense> - All expenses in the system
Supabase Query:
override suspend fun listExpenses(): List<Expense> =
supabaseClient.from("expenses").select().decodeList()
listExpensesByGroup
Fetches all expenses for a specific group.
suspend fun listExpensesByGroup(groupId: String): List<Expense>
The ID of the group to fetch expenses for
Returns: List<Expense> - All expenses in the specified group
Example Usage:
val expenses = expensesRepository.listExpensesByGroup("group-123")
expenses.forEach { expense ->
println("${expense.merchant}: $${expense.amountCents / 100.0}")
}
Supabase Query:
override suspend fun listExpensesByGroup(groupId: String): List<Expense> =
supabaseClient.from("expenses")
.select { filter { eq("group_id", groupId) } }
.decodeList()
listGroupExpenses
Fetches expenses with their splits for a specific group.
suspend fun listGroupExpenses(groupId: String): List<GroupExpense>
The ID of the group to fetch expenses for
Returns: List<GroupExpense> - Expenses with split information
Supabase Query:
override suspend fun listGroupExpenses(groupId: String): List<GroupExpense> =
supabaseClient.from("group_expenses_with_splits")
.select { filter { eq("group_id", groupId) } }
.decodeList()
Database View: Uses group_expenses_with_splits view that joins expenses with their splits
getExpenseById
Fetches a single expense by ID.
suspend fun getExpenseById(expenseId: String): Expense?
The unique identifier of the expense
Returns: Expense? - The expense or null if not found
Supabase Query:
override suspend fun getExpenseById(expenseId: String): Expense? =
supabaseClient.from("expenses")
.select { filter { eq("id", expenseId) } }
.decodeSingleOrNull()
getGroupExpenseById
Fetches a single expense with its splits.
suspend fun getGroupExpenseById(expenseId: String): GroupExpense?
The unique identifier of the expense
Returns: GroupExpense? - The expense with splits or null if not found
Supabase Implementation:
override suspend fun getGroupExpenseById(expenseId: String): GroupExpense? {
val expense = getExpenseById(expenseId) ?: return null
val splits = supabaseClient.from("expense_splits")
.select { filter { eq("expense_id", expenseId) } }
.decodeList<ExpenseSplit>()
return GroupExpense(
id = expense.id,
groupId = expense.groupId,
title = expense.merchant,
amountCents = expense.amountCents,
paidByUserId = expense.paidByUserId,
splits = splits,
createdAt = expense.createdAt
)
}
Creating Expenses
createExpense
Creates a basic expense without custom splits.
suspend fun createExpense(
groupId: String,
description: String,
amountCents: Long,
splitMethod: String
): Expense
The group this expense belongs to
The merchant or description of the expense
The total amount in cents (e.g., 1000 = $10.00)
How to split the expense: “EQUAL”, “PERCENTAGE”, or “EXACT”
Returns: Expense - The created expense
Example Usage:
val expense = expensesRepository.createExpense(
groupId = "group-123",
description = "Dinner at Restaurant",
amountCents = 5000, // $50.00
splitMethod = "EQUAL"
)
Supabase Implementation:
override suspend fun createExpense(
groupId: String,
description: String,
amountCents: Long,
splitMethod: String
): Expense {
val expense = Expense(
groupId = groupId,
merchant = description,
amountCents = amountCents,
splitMethod = splitMethod,
currency = "USD",
paidByUserId = authRepository.getCurrentUserId()
)
return supabaseClient.from("expenses")
.insert(expense) { select() }
.decodeSingle()
}
createExpenseWithSplits
Creates an expense with custom split amounts for each member.
suspend fun createExpenseWithSplits(
groupId: String,
description: String,
amountCents: Long,
currency: String,
splitMethod: String,
splits: List<ExpenseSplit>
): GroupExpense
The group this expense belongs to
The merchant or description of the expense
The total amount in cents
Currency code (e.g., “USD”, “EUR”)
Split method: “EQUAL”, “PERCENTAGE”, “EXACT”
splits
List<ExpenseSplit>
required
List of split allocations for each member
Returns: GroupExpense - The created expense with splits
Example Usage:
val splits = listOf(
ExpenseSplit(userId = "user-1", amountCents = 2000), // $20.00
ExpenseSplit(userId = "user-2", amountCents = 3000) // $30.00
)
val expense = expensesRepository.createExpenseWithSplits(
groupId = "group-123",
description = "Groceries",
amountCents = 5000,
currency = "USD",
splitMethod = "EXACT",
splits = splits
)
Supabase Implementation:
override suspend fun createExpenseWithSplits(
groupId: String,
description: String,
amountCents: Long,
currency: String,
splitMethod: String,
splits: List<ExpenseSplit>
): GroupExpense {
val params = buildJsonObject {
put("p_group_id", groupId)
put("p_merchant", description)
put("p_amount_cents", amountCents)
put("p_currency", currency)
put("p_split_method", splitMethod)
put("p_paid_by", authRepository.getCurrentUserId())
putJsonArray("p_splits") {
splits.forEach { split ->
add(buildJsonObject {
put("user_id", split.userId)
put("amount_cents", split.amountCents)
split.isCoveredBy?.let { put("is_covered_by", it) }
})
}
}
}
val expenseId = supabaseClient.postgrest
.rpc("create_expense_with_splits", params)
.decodeAs<String>()
val groupExpense = getGroupExpenseById(expenseId)
?: error("Failed to fetch expense after creation: $expenseId")
// Update local cache
_expenses.update { map ->
map + (groupId to ((map[groupId] ?: emptyList()) + groupExpense))
}
return groupExpense
}
RPC Function: create_expense_with_splits - Atomically creates expense and splits
Updating Expenses
updateExpense
Updates an expense’s basic properties.
suspend fun updateExpense(
expenseId: String,
groupId: String,
description: String,
amountCents: Long,
splitMethod: String,
currency: String
): Expense
The ID of the expense to update
The group ID (can be changed to move expense)
Updated description/merchant
Returns: Expense - The updated expense
Supabase Implementation:
override suspend fun updateExpense(
expenseId: String,
groupId: String,
description: String,
amountCents: Long,
splitMethod: String,
currency: String
): Expense =
supabaseClient.from("expenses")
.update({
set("group_id", groupId)
set("merchant", description)
set("amount_cents", amountCents)
set("split_method", splitMethod)
set("currency", currency)
}) {
select()
filter { eq("id", expenseId) }
}
.decodeSingle()
updateExpenseSplits
Updates the split allocations for an expense.
suspend fun updateExpenseSplits(expenseId: String, splits: List<ExpenseSplit>)
The ID of the expense to update splits for
splits
List<ExpenseSplit>
required
New split allocations
Example Usage:
val newSplits = listOf(
ExpenseSplit(userId = "user-1", amountCents = 1500),
ExpenseSplit(userId = "user-2", amountCents = 3500)
)
expensesRepository.updateExpenseSplits(
expenseId = "expense-123",
splits = newSplits
)
Supabase Implementation:
override suspend fun updateExpenseSplits(expenseId: String, splits: List<ExpenseSplit>) {
val params = buildJsonObject {
put("p_expense_id", expenseId)
putJsonArray("p_splits") {
splits.forEach { split ->
add(buildJsonObject {
put("user_id", split.userId)
put("amount_cents", split.amountCents)
split.isCoveredBy?.let { put("is_covered_by", it) }
})
}
}
}
supabaseClient.postgrest.rpc("update_expense_splits", params)
}
RPC Function: update_expense_splits - Replaces all splits for an expense
deleteExpense
Deletes an expense and all its splits.
suspend fun deleteExpense(expenseId: String)
The ID of the expense to delete
Supabase Implementation:
override suspend fun deleteExpense(expenseId: String) {
supabaseClient.from("expenses")
.delete { filter { eq("id", expenseId) } }
}
Reactive Observations
observeGroupExpenses
Returns a Flow of expenses for a specific group.
fun observeGroupExpenses(groupId: String): Flow<List<GroupExpense>>
The group ID to observe expenses for
Returns: Flow<List<GroupExpense>> - Stream of expense updates
Example Usage:
val expenses = expensesRepository.observeGroupExpenses("group-123")
.collectAsState(initial = emptyList())
Implementation:
override fun observeGroupExpenses(groupId: String): Flow<List<GroupExpense>> =
_expenses.map { it[groupId] ?: emptyList() }
observeAllGroupExpenses
Returns a Flow of all expenses across all groups.
fun observeAllGroupExpenses(): Flow<List<GroupExpense>>
Returns: Flow<List<GroupExpense>> - Stream of all expenses
Implementation:
override fun observeAllGroupExpenses(): Flow<List<GroupExpense>> =
_expenses.map { it.values.flatten() }
refreshGroupExpenses
Forces a refresh of expenses for a specific group.
suspend fun refreshGroupExpenses(groupId: String)
The group ID to refresh expenses for
Implementation:
override suspend fun refreshGroupExpenses(groupId: String) {
val expenses = listGroupExpenses(groupId)
_expenses.update { it + (groupId to expenses) }
}
refreshAllExpenses
Forces a refresh of all expenses.
suspend fun refreshAllExpenses()
Implementation:
override suspend fun refreshAllExpenses() {
try {
val all = supabaseClient.from("group_expenses_with_splits")
.select()
.decodeList<GroupExpense>()
_expenses.value = all.groupBy { it.groupId }
} catch (_: Exception) { }
}
Data Models
Expense
Source: models/Expense.kt:7
@Serializable
data class Expense(
val id: String = "",
@SerialName("group_id") val groupId: String = "",
val merchant: String = "",
@SerialName("amount_cents") val amountCents: Long = 0L,
@SerialName("split_method") val splitMethod: String = "EQUAL",
val currency: String = "USD",
@SerialName("paid_by_user_id") val paidByUserId: String = "",
@SerialName("created_at") val createdAt: String = ""
)
ExpenseSplit
Source: models/GroupExpense.kt:8
@Serializable
data class ExpenseSplit(
@SerialName("user_id") val userId: String,
@SerialName("amount_cents") val amountCents: Long,
@SerialName("is_covered_by") val isCoveredBy: String? = null
)
Fields:
userId: The member this split belongs to
amountCents: This member’s share of the expense
isCoveredBy: Optional user ID if someone is covering this member’s share
GroupExpense
Source: models/GroupExpense.kt:15
@Serializable
data class GroupExpense(
val id: String,
@SerialName("group_id") val groupId: String,
val title: String,
@SerialName("amount_cents") val amountCents: Long,
@SerialName("paid_by_user_id") val paidByUserId: String,
val splits: List<ExpenseSplit> = emptyList(),
@SerialName("created_at") val createdAt: String
)
Split Calculation Utilities
Source: models/GroupExpense.kt:30
splitEqually
Divides an amount evenly across users:
fun splitEqually(amountCents: Long, userIds: List<String>): List<ExpenseSplit>
Example:
val splits = splitEqually(5000, listOf("user-1", "user-2", "user-3"))
// Result: [1667, 1667, 1666] cents
splitByPercentage
Divides an amount by percentage shares:
fun splitByPercentage(amountCents: Long, percentages: Map<String, Double>): List<ExpenseSplit>
Example:
val splits = splitByPercentage(10000, mapOf(
"user-1" to 40.0, // 40%
"user-2" to 60.0 // 60%
))
// Result: user-1 = 4000, user-2 = 6000
Error Handling
All suspend functions may throw exceptions:
try {
val expense = expensesRepository.createExpenseWithSplits(...)
} catch (e: Exception) {
when {
e.message?.contains("balance") == true ->
showError("Split amounts don't match total")
else ->
showError("Failed to create expense: ${e.message}")
}
}
See Also