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.
{
“@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