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’sDecimalfor 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