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

Source: https://www.techinterview.org/post/3233464625/lld-expense-tracker/
Updated: 2026-04-17 · techinterview.org

## 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](https://techinterview.org/stripe-interview/)

Asked at: [Coinbase Interview Guide](https://techinterview.org/coinbase-interview/)

Asked at: [Shopify Interview Guide](https://techinterview.org/shopify-interview/)

Asked at: [Airbnb Interview Guide](https://techinterview.org/airbnb-interview/)
