python-by-example/projects/02_expense_tracker/solution.py

245 lines
7.4 KiB
Python

"""
================================================================================
Project 02: Expense Tracker
Difficulty: 🟡 Intermediate
================================================================================
A command-line expense tracker that lets users add, view, filter, and analyze
their expenses. Data is persisted to a JSON file between sessions.
Concepts Used:
- Dictionaries and Lists
- File I/O (JSON)
- Functions with parameters
- String formatting
- datetime module
- Data filtering and aggregation
================================================================================
"""
import json
import os
from datetime import datetime
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DATA_FILE = os.path.join(os.path.dirname(__file__), "expenses.json")
CATEGORIES = {
"food": "🍔",
"transport": "🚗",
"entertainment": "🎬",
"bills": "📄",
"other": "📦",
}
# -----------------------------------------------------------------------------
# Data Management
# -----------------------------------------------------------------------------
def load_expenses() -> list:
"""Load expenses from the JSON file."""
if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r") as f:
return json.load(f)
return []
def save_expenses(expenses: list) -> None:
"""Save expenses to the JSON file."""
with open(DATA_FILE, "w") as f:
json.dump(expenses, f, indent=2)
def generate_id(expenses: list) -> int:
"""Generate a unique ID for a new expense."""
if not expenses:
return 1
return max(exp["id"] for exp in expenses) + 1
# -----------------------------------------------------------------------------
# Core Features
# -----------------------------------------------------------------------------
def add_expense(expenses: list) -> None:
"""Add a new expense."""
description = input(" Enter description: ").strip()
if not description:
print(" ❌ Description cannot be empty.")
return
try:
amount = float(input(" Enter amount: $"))
if amount <= 0:
print(" ❌ Amount must be positive.")
return
except ValueError:
print(" ❌ Invalid amount.")
return
print(f" Categories: {', '.join(CATEGORIES.keys())}")
category = input(" Enter category: ").strip().lower()
if category not in CATEGORIES:
print(f" ⚠️ Unknown category. Defaulting to 'other'.")
category = "other"
expense = {
"id": generate_id(expenses),
"description": description,
"amount": amount,
"category": category,
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
expenses.append(expense)
save_expenses(expenses)
print(f" ✅ Expense added: {description} — ${amount:.2f} [{category}]")
def view_expenses(expenses: list, filter_category: str = None) -> None:
"""Display expenses in a formatted table."""
filtered = expenses
if filter_category:
filtered = [e for e in expenses if e["category"] == filter_category]
if not filtered:
print(" 📭 No expenses found.")
return
# Header
print(f"\n {'ID':<4} {'Date':<18} {'Category':<15} {'Description':<20} {'Amount':>10}")
print(f" {''*4} {''*18} {''*15} {''*20} {''*10}")
# Rows
for exp in filtered:
icon = CATEGORIES.get(exp["category"], "📦")
print(
f" {exp['id']:<4} "
f"{exp['date']:<18} "
f"{icon} {exp['category']:<12} "
f"{exp['description']:<20} "
f"${exp['amount']:>8.2f}"
)
total = sum(e["amount"] for e in filtered)
print(f"\n {'Total:':<60} ${total:>8.2f}")
def view_by_category(expenses: list) -> None:
"""Filter and view expenses by category."""
print(f" Categories: {', '.join(CATEGORIES.keys())}")
category = input(" Enter category to filter: ").strip().lower()
if category not in CATEGORIES:
print(" ❌ Invalid category.")
return
view_expenses(expenses, filter_category=category)
def show_summary(expenses: list) -> None:
"""Display spending summary with category breakdown."""
if not expenses:
print(" 📭 No expenses to summarize.")
return
total = sum(e["amount"] for e in expenses)
# Group by category
by_category = {}
for exp in expenses:
cat = exp["category"]
by_category[cat] = by_category.get(cat, 0) + exp["amount"]
print(f"\n 📊 EXPENSE SUMMARY")
print(f" {'='*40}")
print(f" Total Expenses: ${total:,.2f}")
print(f" Total Items: {len(expenses)}")
print(f" {''*40}")
# Sort categories by amount (descending)
sorted_cats = sorted(by_category.items(), key=lambda x: x[1], reverse=True)
for cat, amount in sorted_cats:
icon = CATEGORIES.get(cat, "📦")
percentage = (amount / total) * 100
bar = "" * int(percentage / 5)
print(f" {icon} {cat:<15} ${amount:>8.2f} ({percentage:>5.1f}%) {bar}")
def delete_expense(expenses: list) -> None:
"""Delete an expense by its ID."""
view_expenses(expenses)
if not expenses:
return
try:
exp_id = int(input("\n Enter expense ID to delete: "))
except ValueError:
print(" ❌ Invalid ID.")
return
for i, exp in enumerate(expenses):
if exp["id"] == exp_id:
removed = expenses.pop(i)
save_expenses(expenses)
print(f" 🗑️ Deleted: {removed['description']} — ${removed['amount']:.2f}")
return
print(f" ❌ Expense with ID {exp_id} not found.")
# -----------------------------------------------------------------------------
# Main Menu
# -----------------------------------------------------------------------------
def display_menu():
"""Display the main menu."""
print("""
╔══════════════════════════════════════════╗
║ 💰 EXPENSE TRACKER 💰 ║
╠══════════════════════════════════════════╣
║ 1. Add Expense ║
║ 2. View All Expenses ║
║ 3. View by Category ║
║ 4. Summary ║
║ 5. Delete Expense ║
║ 6. Exit ║
╚══════════════════════════════════════════╝
""")
def main():
"""Main entry point."""
expenses = load_expenses()
while True:
display_menu()
choice = input(" Choose an option (1-6): ").strip()
if choice == "1":
add_expense(expenses)
elif choice == "2":
view_expenses(expenses)
elif choice == "3":
view_by_category(expenses)
elif choice == "4":
show_summary(expenses)
elif choice == "5":
delete_expense(expenses)
elif choice == "6":
print("\n 👋 Goodbye! Keep tracking your expenses!")
break
else:
print(" ❌ Invalid option. Please choose 1-6.")
input("\n Press Enter to continue...")
if __name__ == "__main__":
main()