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.

Asked at: Stripe Interview Guide

Asked at: Coinbase Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top