Capstone — Reference Implementation
Checking access...
Here’s a complete reference implementation. Use it as a guide or compare your approach.
finance_tracker/models.py
"""Data models for the finance tracker."""
from dataclasses import dataclass, fieldfrom datetime import datetimefrom decimal import Decimalfrom enum import Enumfrom typing import Optional
class TransactionType(Enum): INCOME = "income" EXPENSE = "expense"
@dataclassclass Transaction: """A single financial transaction."""
id: int amount: Decimal transaction_type: TransactionType category: str description: str date: datetime created_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> dict: return { "id": self.id, "amount": str(self.amount), "type": self.transaction_type.value, "category": self.category, "description": self.description, "date": self.date.isoformat(), "created_at": self.created_at.isoformat(), }
@classmethod def from_dict(cls, data: dict) -> "Transaction": return cls( id=data["id"], amount=Decimal(data["amount"]), transaction_type=TransactionType(data["type"]), category=data["category"], description=data["description"], date=datetime.fromisoformat(data["date"]), created_at=datetime.fromisoformat(data["created_at"]), )
DEFAULT_CATEGORIES = { "income": ["Salary", "Freelance", "Investments", "Gifts"], "expense": ["Food", "Housing", "Transport", "Utilities", "Entertainment", "Healthcare", "Education", "Shopping"],}finance_tracker/storage.py
"""JSON-based persistence for the finance tracker."""
import jsonfrom pathlib import Pathfrom typing import List, Optional
from .models import Transaction
class TransactionStorage: """Handle JSON persistence of transactions."""
def __init__(self, filepath: Optional[Path] = None): self.filepath = filepath or Path.home() / ".finance_tracker" / "transactions.json" self.filepath.parent.mkdir(parents=True, exist_ok=True)
def load(self) -> List[Transaction]: """Load all transactions from storage.""" if not self.filepath.exists(): return [] try: data = json.loads(self.filepath.read_text()) return [Transaction.from_dict(item) for item in data] except (json.JSONDecodeError, KeyError): return []
def save(self, transactions: List[Transaction]): """Save all transactions to storage.""" data = [t.to_dict() for t in transactions] self.filepath.write_text(json.dumps(data, indent=2))
def add(self, transaction: Transaction) -> Transaction: """Add a single transaction.""" transactions = self.load() transactions.append(transaction) self.save(transactions) return transaction
def delete(self, transaction_id: int) -> bool: """Delete a transaction by ID.""" transactions = self.load() for i, t in enumerate(transactions): if t.id == transaction_id: transactions.pop(i) self.save(transactions) return True return False
def get_next_id(self) -> int: """Get the next available transaction ID.""" transactions = self.load() if not transactions: return 1 return max(t.id for t in transactions) + 1
def clear(self): """Clear all transactions (for testing).""" if self.filepath.exists(): self.filepath.unlink()finance_tracker/analysis.py
"""Analysis and reporting for the finance tracker."""
from collections import defaultdictfrom datetime import datetimefrom decimal import Decimalfrom typing import Dict, List, Tuple
from .models import Transaction, TransactionType
class FinanceAnalyzer: """Generate financial reports and summaries."""
def __init__(self, transactions: List[Transaction]): self.transactions = sorted(transactions, key=lambda t: t.date)
def balance(self) -> Decimal: """Calculate current balance.""" total = Decimal("0") for t in self.transactions: if t.transaction_type == TransactionType.INCOME: total += t.amount else: total -= t.amount return total
def total_income(self) -> Decimal: return sum( (t.amount for t in self.transactions if t.transaction_type == TransactionType.INCOME), Decimal("0"), )
def total_expenses(self) -> Decimal: return sum( (t.amount for t in self.transactions if t.transaction_type == TransactionType.EXPENSE), Decimal("0"), )
def monthly_summary(self, year: int, month: int) -> Dict: """Get summary for a specific month.""" monthly = [ t for t in self.transactions if t.date.year == year and t.date.month == month ] analyzer = FinanceAnalyzer(monthly) return { "month": f"{year}-{month:02d}", "income": analyzer.total_income(), "expenses": analyzer.total_expenses(), "net": analyzer.balance(), "transaction_count": len(monthly), }
def category_breakdown(self) -> Dict[str, Decimal]: """Get total expenses by category.""" breakdown = defaultdict(Decimal) for t in self.transactions: if t.transaction_type == TransactionType.EXPENSE: breakdown[t.category] += t.amount return dict(sorted(breakdown.items(), key=lambda x: x[1], reverse=True))
def income_by_category(self) -> Dict[str, Decimal]: """Get total income by category.""" breakdown = defaultdict(Decimal) for t in self.transactions: if t.transaction_type == TransactionType.INCOME: breakdown[t.category] += t.amount return dict(sorted(breakdown.items(), key=lambda x: x[1], reverse=True))
def cash_flow(self, months: int = 6) -> List[Tuple[str, Decimal, Decimal]]: """Get monthly cash flow for the last N months.""" if not self.transactions: return []
latest = max(t.date for t in self.transactions) flow = [] for i in range(months - 1, -1, -1): year = latest.year month = latest.month - i while month < 1: month += 12 year -= 1 summary = self.monthly_summary(year, month) flow.append(( f"{year}-{month:02d}", summary["income"], summary["expenses"], )) return flow
def top_expenses(self, n: int = 5) -> List[Transaction]: """Get the N largest expenses.""" expenses = [t for t in self.transactions if t.transaction_type == TransactionType.EXPENSE] return sorted(expenses, key=lambda t: t.amount, reverse=True)[:n]finance_tracker/cli.py
"""Command-line interface for the finance tracker."""
import argparsefrom datetime import datetimefrom decimal import Decimal, InvalidOperationfrom pathlib import Pathfrom typing import Optional
from .models import Transaction, TransactionType, DEFAULT_CATEGORIESfrom .storage import TransactionStoragefrom .analysis import FinanceAnalyzer
class FinanceCLI: """CLI handler for the finance tracker."""
def __init__(self): self.storage = TransactionStorage()
def _get_analyzer(self) -> FinanceAnalyzer: transactions = self.storage.load() return FinanceAnalyzer(transactions)
def add(self, args): """Add a new transaction.""" try: amount = Decimal(str(args.amount)) if amount <= 0: raise ValueError("Amount must be positive") except (InvalidOperation, ValueError): print("Error: Invalid amount") return
try: txn_type = TransactionType(args.type) except ValueError: print(f"Error: Type must be 'income' or 'expense'") return
if args.date: try: date = datetime.strptime(args.date, "%Y-%m-%d") except ValueError: print("Error: Date must be YYYY-MM-DD") return else: date = datetime.now()
transaction = Transaction( id=self.storage.get_next_id(), amount=amount, transaction_type=txn_type, category=args.category, description=args.description, date=date, ) self.storage.add(transaction) print(f"Added: {transaction}")
def list(self, args): """List transactions with optional filters.""" transactions = self.storage.load()
if args.month and args.year: transactions = [ t for t in transactions if t.date.year == args.year and t.date.month == args.month ]
if args.type: try: txn_type = TransactionType(args.type) transactions = [t for t in transactions if t.transaction_type == txn_type] except ValueError: pass
if args.category: transactions = [t for t in transactions if t.category.lower() == args.category.lower()]
if not transactions: print("No transactions found.") return
print(f"{'ID':<4} {'Date':<12} {'Type':<8} {'Amount':<10} {'Category':<15} Description") print("-" * 75) for t in transactions: print(f"{t.id:<4} {t.date.strftime('%Y-%m-%d'):<12} " f"{t.transaction_type.value:<8} ${t.amount:<8.2f} " f"{t.category:<15} {t.description}")
total = sum(t.amount for t in transactions) print("-" * 75) print(f"{'Total:':>34} ${total:.2f}")
def balance(self, args): """Show current balance.""" analyzer = self._get_analyzer() bal = analyzer.balance() income = analyzer.total_income() expenses = analyzer.total_expenses() print(f"Total Income: ${income:>8.2f}") print(f"Total Expenses: ${expenses:>8.2f}") print(f"{'─' * 25}") print(f"Balance: ${bal:>8.2f}")
def report(self, args): """Generate financial report.""" analyzer = self._get_analyzer()
if args.monthly: now = datetime.now() year = args.year or now.year month = args.month or now.month summary = analyzer.monthly_summary(year, month) print(f"\n=== Monthly Report: {summary['month']} ===") print(f"Income: ${summary['income']:>8.2f}") print(f"Expenses: ${summary['expenses']:>8.2f}") print(f"Net: ${summary['net']:>8.2f}") print(f"Transactions: {summary['transaction_count']}") else: print(f"\n=== Category Breakdown ===") for cat, amount in analyzer.category_breakdown().items(): bar = "█" * int(amount / max(analyzer.category_breakdown().values()) * 30) print(f" {cat:<15} ${amount:<8.2f} {bar}")
print(f"\n=== Top 5 Expenses ===") for t in analyzer.top_expenses(5): print(f" ${t.amount:<8.2f} {t.date.strftime('%Y-%m-%d')} {t.category} — {t.description}")
if args.forecast: print(f"\n=== Cash Flow (Last 6 Months) ===") for period, income, expenses in analyzer.cash_flow(6): print(f" {period}: +${income:<8.2f} -${expenses:<8.2f} = ${income - expenses:.2f}")
def export(self, args): """Export transactions to CSV or JSON.""" import csv import json
transactions = self.storage.load() if not transactions: print("No transactions to export.") return
output_path = Path(args.output) if args.output else Path("transactions_export.csv")
if args.format == "csv": with open(output_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["ID", "Date", "Type", "Amount", "Category", "Description"]) for t in transactions: writer.writerow([ t.id, t.date.strftime("%Y-%m-%d"), t.transaction_type.value, str(t.amount), t.category, t.description, ]) else: data = [t.to_dict() for t in transactions] output_path.write_text(json.dumps(data, indent=2))
print(f"Exported {len(transactions)} transactions to {output_path}")
def create_parser() -> argparse.ArgumentParser: """Create the argument parser.""" parser = argparse.ArgumentParser( description="Personal Finance Tracker", formatter_class=argparse.RawDescriptionHelpFormatter, )
sub = parser.add_subparsers(dest="command")
# add add_p = sub.add_parser("add", help="Add a transaction") add_p.add_argument("-a", "--amount", required=True, help="Transaction amount") add_p.add_argument("-t", "--type", choices=["income", "expense"], required=True) add_p.add_argument("-c", "--category", default="General", help="Category") add_p.add_argument("-d", "--description", default="", help="Description") add_p.add_argument("--date", help="Date (YYYY-MM-DD, default: today)")
# list list_p = sub.add_parser("list", aliases=["ls"], help="List transactions") list_p.add_argument("--month", type=int, help="Month (1-12)") list_p.add_argument("--year", type=int, help="Year (e.g., 2024)") list_p.add_argument("--type", choices=["income", "expense"], help="Filter by type") list_p.add_argument("--category", help="Filter by category")
# balance sub.add_parser("balance", help="Show balance")
# report report_p = sub.add_parser("report", help="Generate report") report_p.add_argument("--monthly", action="store_true", help="Monthly summary") report_p.add_argument("--month", type=int, help="Month for report") report_p.add_argument("--year", type=int, help="Year for report") report_p.add_argument("--forecast", action="store_true", help="Show cash flow")
# export export_p = sub.add_parser("export", help="Export transactions") export_p.add_argument("--format", choices=["csv", "json"], default="csv") export_p.add_argument("-o", "--output", help="Output file path")
return parser
def main(): parser = create_parser() args = parser.parse_args()
cli = FinanceCLI()
commands = { "add": cli.add, "list": cli.list, "ls": cli.list, "balance": cli.balance, "report": cli.report, "export": cli.export, }
if args.command in commands: commands[args.command](args) else: parser.print_help()
if __name__ == "__main__": main()Test Files
finance_tracker/tests/test_models.py:
import pytestfrom datetime import datetimefrom decimal import Decimalfrom finance_tracker.models import Transaction, TransactionType
class TestTransaction: def test_create_transaction(self): t = Transaction( id=1, amount=Decimal("50.00"), transaction_type=TransactionType.INCOME, category="Salary", description="Payment", date=datetime.now(), ) assert t.amount == Decimal("50.00") assert t.transaction_type == TransactionType.INCOME
def test_to_dict_roundtrip(self): t = Transaction( id=1, amount=Decimal("25.50"), transaction_type=TransactionType.EXPENSE, category="Food", description="Lunch", date=datetime(2024, 12, 1), ) data = t.to_dict() t2 = Transaction.from_dict(data) assert t.id == t2.id assert t.amount == t2.amount assert t.transaction_type == t2.transaction_typefinance_tracker/tests/test_analysis.py:
from decimal import Decimalfrom datetime import datetimefrom finance_tracker.models import Transaction, TransactionTypefrom finance_tracker.analysis import FinanceAnalyzer
def make_tx(id, amount, type_, category, days_ago=0): from datetime import timedelta return Transaction( id=id, amount=Decimal(str(amount)), transaction_type=type_, category=category, description="test", date=datetime.now() - timedelta(days=days_ago), )
class TestFinanceAnalyzer: def test_balance(self): transactions = [ make_tx(1, 1000, TransactionType.INCOME, "Salary", 10), make_tx(2, 200, TransactionType.EXPENSE, "Food", 5), make_tx(3, 50, TransactionType.EXPENSE, "Transport", 1), ] analyzer = FinanceAnalyzer(transactions) assert analyzer.balance() == Decimal("750.00")
def test_category_breakdown(self): transactions = [ make_tx(1, 100, TransactionType.EXPENSE, "Food", 5), make_tx(2, 200, TransactionType.EXPENSE, "Food", 3), make_tx(3, 50, TransactionType.EXPENSE, "Transport", 1), ] analyzer = FinanceAnalyzer(transactions) breakdown = analyzer.category_breakdown() assert breakdown["Food"] == Decimal("300.00") assert breakdown["Transport"] == Decimal("50.00")Setup and Run
# Create the package structuremkdir -p finance_tracker/tests
# Create __init__.py filestouch finance_tracker/__init__.pytouch finance_tracker/tests/__init__.py
# Install in development modepip install -e .
# Run the CLIpython -m finance_tracker.cli add -a 1000 -t income -c Salary -d "January pay"python -m finance_tracker.cli add -a 200 -t expense -c Food -d "Groceries"python -m finance_tracker.cli balancepython -m finance_tracker.cli report --forecast
# Run testspytest finance_tracker/tests/ -v