How TREK's Budget Addon Handles Expense Splitting and Settlement
TREK's Budget addon automatically splits trip expenses among assigned members and calculates the minimal set of payments required to settle debts using a greedy matching algorithm that converts all amounts to a base currency and balances payer credits against member debits.
The TREK repository includes a sophisticated Budget addon that handles multi-currency expense tracking and automated debt settlement. Understanding how TREK's expense splitting and settlement system works reveals a robust backend architecture that persists granular payment data in SQLite tables and applies currency-aware algorithms to minimize transfer complexity across trip participants.
Database Architecture for Budget Tracking
The settlement system relies on four interconnected tables that separate expense metadata from payment responsibility.
budget_items stores the core expense data including total amount, currency code, and an optional exchange_rate field. When a trip creator records a new expense, this row captures the transaction details while remaining agnostic about who actually pays.
budget_item_members creates the split relationship by linking users to specific items. Each row contains a paid boolean flag that the UI toggles when marking members as settled, though this flag serves display purposes only and does not affect the mathematical settlement calculation.
budget_item_payers records the actual monetary sources. When one or more users contribute cash toward an expense, rows here store the amount each payer contributed. If this table contains no entries for an item, the system assumes the total amount listed in budget_items represents the full payment.
budget_settlements persists manual transfers between users after the algorithm suggests settlement flows. These historic transactions are automatically factored into future balance calculations to prevent double-counting debts.
The Settlement Calculation Engine
The core logic resides in server/src/services/budgetService.ts, where two primary functions orchestrate the debt resolution process.
Per-Person Balance Aggregation
The getPerPersonSummary() function aggregates financial responsibility across all trip expenses. It calculates each member's total assigned share of expenses and subtracts any amounts they have already paid via explicit payer rows or previous settlements. The method returns an array containing user_id, total_assigned, total_paid, and items_count for every participant.
Currency Normalization
Before running calculations, the system converts all monetary values to a common base currency (defaulting to the trip's currency). If a budget_items row contains a frozen exchange_rate, that value is applied directly. Otherwise, the system calls getRates() to fetch live foreign exchange rates and applies the current conversion to normalize multi-currency trips.
The Greedy Settlement Algorithm
The calculateSettlement() function implements a flow minimization algorithm that produces the smallest number of transactions required to zero all balances. The implementation follows these steps:
// 1️⃣ Load all items, members and payers for the trip
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId);
const members = db.prepare('SELECT … FROM budget_item_members …').all(tripId);
const payers = db.prepare('SELECT … FROM budget_item_payers …').all(tripId);
// 2️⃣ For each item compute the total paid in the *base* currency
const paidBase = payers.reduce(
(a, p) => a + toBase(p.amount, item.currency, item.exchange_rate), 0);
// 3️⃣ Split the paid amount evenly among the members that are assigned to the item
const sharePerMember = paidBase / members.length;
// 4️⃣ Update the balance map:
// – each payer gets a credit for what they actually paid
// – each member gets a debit for the equal share
ensure(payerId, …).balance += toBase(p.amount, …);
ensure(memberId, …).balance -= sharePerMember;
// 5️⃣ Apply persisted settlements (recorded transfers)
ensureSettled(s.from_user_id, …).balance += s.amount;
ensureSettled(s.to_user_id, …).balance -= s.amount;
// 6️⃣ Greedy matching of remaining debtors ↔ creditors
while (debtors && creditors) {
const transfer = Math.min(debtor.amount, creditor.amount);
flows.push({ from: debtor, to: creditor, amount: round(transfer) });
debtor.amount -= transfer;
creditor.amount -= transfer;
}
The algorithm orders debtors and creditors by outstanding amount and matches the largest debts against the largest credits, yielding the minimal number of transfers in practice without attempting computationally expensive combinatorial optimization.
Working with the Budget Service API
The NestJS wrapper in server/src/nest/budget/budget.service.ts exposes these methods for client applications.
Listing Budget Items
Retrieve all expenses for a specific trip:
import { BudgetService } from './server/src/nest/budget/budget.service';
async function showBudget(tripId: string) {
const svc = new BudgetService();
const items = await svc.list(tripId);
console.log('Budget items →', items);
}
Calculating Per-Person Balances
Get a summary of who owes or is owed money:
async function summary(tripId: string) {
const svc = new BudgetService();
const summary = await svc.perPersonSummary(tripId);
// → [{ user_id, username, total_assigned, total_paid, items_count }]
console.log(summary);
}
Computing Settlement Flows
Generate the optimized payment instructions:
async function settlement(tripId: string, baseCurrency?: string) {
const svc = new BudgetService();
// `baseCurrency` is the currency the UI wants the result in (e.g. "USD")
const result = await svc.settlement(tripId, baseCurrency, 'EUR');
console.log('Balances:', result.balances);
console.log('Transfer flows:', result.flows);
}
Recording Manual Settlements
Persist a payment between users to update future calculations:
async function settle(tripId: string, fromUserId: number, toUserId: number, amount: number) {
const svc = new BudgetService();
const settlement = await svc.createSettlement(tripId, {
from_user_id: fromUserId,
to_user_id: toUserId,
amount,
}, /* createdByUserId */ fromUserId);
console.log('Created settlement →', settlement);
}
Marking Members as Paid
Toggle the visual paid status for UI feedback:
async function markPaid(itemId: string, tripId: string, userId: number) {
const svc = new BudgetService();
const member = await svc.toggleMemberPaid(itemId, tripId, `${userId}`, true);
console.log('Member marked paid →', member);
}
UI Integration and Payment Workflows
The frontend interacts with the settlement system through specific HTTP endpoints that map to the service methods.
When a user edits the Persons column in the budget interface, the front-end sends a PUT /budget/:id request that triggers BudgetService.updateMembers to modify budget_item_members associations. Clicking a member avatar toggles the paid flag via BudgetService.toggleMemberPaid, which updates the database row but only affects the green outline indicator in the UI.
The actual settlement calculation occurs when the UI calls GET /budget/settlement, implemented by BudgetController.settlement and forwarded to BudgetService.settlement → calculateSettlement. This endpoint returns the net balances, the greedy flow list, and any historic settlements for the trip.
When a user confirms a suggested transfer, the UI POSTs to /budget/settlements, which invokes BudgetService.createSettlement → createSettlement to persist the transfer in budget_settlements. Subsequent calculations automatically incorporate this payment to adjust the affected users' balances.
Summary
- Four-database design separates expense items (
budget_items), split assignments (budget_item_members), explicit payers (budget_item_payers), and historic settlements (budget_settlements). - Currency handling converts all amounts to a base currency using frozen exchange rates from the expense record or live rates via
getRates(). - Balance calculation occurs in
getPerPersonSummary(), which aggregates shares and subtracts payments to determine net positions. - Greedy optimization in
calculateSettlement()matches debtors with creditors by amount to minimize the total number of required transactions. - API exposure through
BudgetServiceprovides methods for listing items, calculating summaries, generating flows, and recording settlements.
Frequently Asked Questions
How does TREK handle multiple currencies in expense splitting?
The Budget addon automatically converts all expenses to a common base currency (defaulting to the trip currency) before running calculations. If an expense item includes a frozen exchange_rate in budget_items, that rate is used for consistency. Otherwise, the system fetches live FX rates via getRates() to normalize amounts, ensuring accurate splitting even when trip members pay in different currencies.
What is the difference between marking a member as paid and recording a settlement?
The paid flag in budget_item_members is a UI-only indicator that toggles a green avatar outline to show visual progress; it does not affect the mathematical settlement. Recording a settlement via createSettlement() persists an actual monetary transfer in budget_settlements, which permanently adjusts the users' balances and factors into future calculations. The settlement algorithm only considers payer rows and explicit settlements, not the visual paid flag.
How does the algorithm minimize the number of payments needed?
calculateSettlement() implements a greedy matching algorithm that orders all debtors and creditors by their outstanding amounts. It repeatedly matches the largest remaining debtor with the largest creditor, creating a transfer for the smaller of the two amounts. This approach produces the minimal practical set of transactions without exhaustive combinatorial search, as implemented in server/src/services/budgetService.ts.
Where are the exchange rates stored for budget calculations?
Exchange rates are handled dynamically rather than stored permanently. When calculateSettlement() processes an expense, it checks for a frozen exchange_rate value in the budget_items row. If absent, the system calls getRates() to retrieve current foreign exchange data. This design allows the settlement engine to handle historical expenses with locked rates while supporting real-time conversion for new entries.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →