Low-Level Design: Expense Tracker — Multi-Currency, Budgets, and Expense Splitting

Requirements

Functional: record expenses with amount, category, date, and notes; support multiple currencies with conversion; tag and categorize expenses; view summaries (by month, category, date range); set budgets per category and alert when approaching; split expenses among users (Splitwise-style); export data (CSV, PDF).

Non-functional: all monetary arithmetic uses integer cents (no floating point), all amounts stored in a single base currency (USD), historical exchange rates preserved on each transaction.

Core Entities

from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, List, Dict
from datetime import date, datetime
from decimal import Decimal

class Category(Enum):
    FOOD          = "FOOD"
    TRANSPORT     = "TRANSPORT"
    HOUSING       = "HOUSING"
    ENTERTAINMENT = "ENTERTAINMENT"
    HEALTHCARE    = "HEALTHCARE"
    UTILITIES     = "UTILITIES"
    SHOPPING      = "SHOPPING"
    OTHER         = "OTHER"

@dataclass
class Expense:
    expense_id: str
    user_id: str
    amount_cents: int              # always in base currency (USD)
    original_amount_cents: int     # original currency amount
    original_currency: str         # 'EUR', 'GBP', 'JPY', etc.
    exchange_rate: Decimal         # rate used at time of entry
    category: Category
    date: date
    description: str
    tags: List[str] = field(default_factory=list)
    receipt_url: Optional[str] = None
    split_id: Optional[str] = None   # if part of a shared expense

@dataclass
class Budget:
    budget_id: str
    user_id: str
    category: Category
    period: str                    # 'monthly' | 'yearly'
    limit_cents: int
    alert_threshold_pct: int = 80  # alert at 80% of budget

@dataclass
class Split:
    split_id: str
    description: str
    total_amount_cents: int
    payer_id: str
    participants: List[str]
    shares: Dict[str, int]         # user_id -> amount_cents they owe
    settled: Dict[str, bool]       # user_id -> is settled
    created_at: datetime

Expense Service

class ExpenseService:
    def add_expense(self, user_id: str, amount: int, currency: str,
                    category: Category, expense_date: date, description: str,
                    tags: List[str] = None) -> Expense:
        rate = self._get_exchange_rate(currency, 'USD', expense_date)
        amount_usd = int(amount * float(rate))
        expense = Expense(
            expense_id=generate_id(),
            user_id=user_id,
            amount_cents=amount_usd,
            original_amount_cents=amount,
            original_currency=currency,
            exchange_rate=rate,
            category=category,
            date=expense_date,
            description=description,
            tags=tags or [],
        )
        db.save(expense)
        self._check_budget_alert(user_id, category, expense_date)
        return expense

    def _get_exchange_rate(self, from_currency: str, to_currency: str, date: date) -> Decimal:
        if from_currency == to_currency:
            return Decimal('1.0')
        # Fetch historical rate from exchange rate service (e.g., Open Exchange Rates)
        rate = exchange_rate_api.get_rate(from_currency, to_currency, date)
        return Decimal(str(rate))

    def get_summary(self, user_id: str, start_date: date, end_date: date) -> dict:
        expenses = db.query(
            "SELECT * FROM expenses WHERE user_id = %s AND date BETWEEN %s AND %s",
            [user_id, start_date, end_date]
        )
        by_category = {}
        for exp in expenses:
            cat = exp.category.value
            by_category[cat] = by_category.get(cat, 0) + exp.amount_cents
        return {
            'total_cents': sum(e.amount_cents for e in expenses),
            'by_category': by_category,
            'expense_count': len(expenses),
            'date_range': {'start': start_date.isoformat(), 'end': end_date.isoformat()},
        }

Budget Tracking and Alerts

class BudgetService:
    def set_budget(self, user_id: str, category: Category,
                   limit_cents: int, period: str = 'monthly') -> Budget:
        budget = Budget(
            budget_id=generate_id(),
            user_id=user_id,
            category=category,
            period=period,
            limit_cents=limit_cents,
        )
        db.save(budget)
        return budget

    def get_budget_status(self, user_id: str, category: Category,
                          year: int, month: int) -> dict:
        budget = db.get_budget(user_id, category)
        if not budget:
            return {'has_budget': False}
        spent = db.sum_expenses(user_id, category, year, month)
        pct_used = (spent / budget.limit_cents * 100) if budget.limit_cents > 0 else 0
        return {
            'has_budget': True,
            'limit_cents': budget.limit_cents,
            'spent_cents': spent,
            'remaining_cents': max(0, budget.limit_cents - spent),
            'pct_used': round(pct_used, 1),
            'over_budget': spent > budget.limit_cents,
        }

    def check_and_alert(self, user_id: str, category: Category, year: int, month: int):
        status = self.get_budget_status(user_id, category, year, month)
        if not status['has_budget']: return
        if status['pct_used'] >= 80 and status['pct_used'] < 100:
            notify_user(user_id, f"You've used {status['pct_used']}% of your {category.value} budget")
        elif status['over_budget']:
            notify_user(user_id, f"Over budget for {category.value}!")

Expense Splitting

class SplitService:
    def create_split(self, payer_id: str, total_cents: int,
                     participants: List[str], description: str,
                     split_type: str = 'equal') -> Split:
        shares = self._calculate_shares(total_cents, participants, split_type)
        split = Split(
            split_id=generate_id(),
            description=description,
            total_amount_cents=total_cents,
            payer_id=payer_id,
            participants=participants,
            shares=shares,
            settled={uid: (uid == payer_id) for uid in participants},
            created_at=datetime.utcnow(),
        )
        db.save(split)
        # Create individual expense records for each participant
        for uid in participants:
            if uid != payer_id:
                expense_service.add_expense(
                    uid, shares[uid], 'USD', Category.OTHER,
                    date.today(), f"Split: {description}", split_id=split.split_id
                )
        return split

    def _calculate_shares(self, total: int, participants: List[str], split_type: str) -> Dict[str, int]:
        if split_type == 'equal':
            per_person = total // len(participants)
            remainder = total % len(participants)
            shares = {uid: per_person for uid in participants}
            # Give remainder to first participant (payer absorbs rounding)
            shares[participants[0]] += remainder
            return shares
        raise ValueError(f"Unknown split type: {split_type}")

    def settle(self, split_id: str, user_id: str):
        split = db.get_split(split_id)
        if user_id not in split.participants:
            raise ValueError("Not a participant")
        split.settled[user_id] = True
        db.save(split)

Currency Handling Best Practices

  • Always store in integer cents: never use float for money. 10.10 * 100 = 1009.9999... in floating point. Use Python’s Decimal for intermediate calculations.
  • Store original currency and rate: preserve the original amount and exchange rate on each expense for audit and display purposes.
  • Historical rates: use the exchange rate at the date of the expense, not today’s rate. Rates change — converting at the current rate would make historical data inconsistent.
  • Rounding rules: for splits, round down per share and give the remainder to one participant (usually the payer). Document the rounding rule to avoid disputes.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why store monetary amounts as integer cents instead of floating-point?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Floating-point numbers (float, double) cannot represent most decimal fractions exactly. 0.1 + 0.2 = 0.30000000000000004 in IEEE 754. For money: 10.10 * 100 = 1009.9999… instead of 1010. These errors accumulate across many transactions and produce incorrect totals. Integer cents (10 dollars = 1000 cents) are always exact. For currencies with no decimal (JPY, KRW), use 1 unit = 1 cent. For currencies with 3 decimal places (KWD), use millicents. Use Python’s Decimal or Java’s BigDecimal for intermediate calculations (exchange rate multiplication) and round to integer cents before storing.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle multi-currency expense tracking?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Store a base currency (USD) amount for all analytics and comparisons. On each expense, record three fields: original_amount (in the original currency), original_currency, and exchange_rate (rate at time of entry). Convert to base currency: amount_cents = round(original_amount * exchange_rate). Use historical exchange rates u2014 fetch the rate for the date of the expense, not today’s rate. Historical rates preserve accuracy when reviewing past expenses. Store exchange_rate as Decimal for precision. Display expenses in the original currency for the user but aggregate and compare in the base currency.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement fair expense splitting with rounding?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “For equal splits: per_person = total // n_participants (integer division). Remainder = total % n_participants (0 to n-1 cents). Assign the remainder to one participant (typically the payer) so that all shares sum exactly to the total. Never round each share independently u2014 that would result in shares summing to more or less than the total. For percentage splits: compute each share as int(total * pct / 100), assign the remainder (total – sum(shares)) to the largest-share participant. Document the rounding rule so all participants agree on the method before disputes arise.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement budget alerts that fire at 80% of limit?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “On each expense creation, query total spending for the user + category + current month and compare to the budget limit. If the new total crosses the 80% threshold (and wasn’t over it before the expense), emit an alert. Store the last alert level in the budget record (e.g., “alerted_at_80″) to avoid re-alerting on every subsequent expense after the threshold is crossed. Check again at 100% for over-budget alert. Schedule a daily job to check all budgets u2014 catches cases where alerts were missed (e.g., batch import of expenses). Use push notifications or email for delivery.”
}
},
{
“@type”: “Question”,
“name”: “How would you scale an expense tracker to millions of users?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Shard the expenses table by user_id u2014 most queries are user-scoped (my expenses, my summary). Summary queries (monthly totals by category) are pre-computed by a batch job and cached in Redis u2014 avoid re-scanning millions of rows on every dashboard load. Exchange rate API calls are cached by (currency_pair, date) with a 24h TTL. For CSV/PDF export, generate asynchronously via a task queue and email the download link u2014 avoid blocking the API on large exports. Full-text search on expense descriptions uses Elasticsearch. Time-series aggregations (spending trends) use a materialized table updated by a streaming job.”
}
}
]
}

Asked at: Stripe Interview Guide

Asked at: Coinbase Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top