Plutus â Expense Tracker & Budget Analyser
Turn a messy list of transactions into a clean, categorised expense report with totals, trends, and budget comparison â no spreadsheet required.
What you get
- Auto-categorisation of transactions into 15+ categories using keyword matching
- Totals by category with percentage breakdown
- Monthly trend table showing spend per category per month
- Budget vs actual comparison (set your own limits via
BUDGET_JSON) - Top 10 biggest transactions highlighted
- Export: Markdown report + CSV summary saved to disk
Input formats
CSV file (set EXPENSES_FILE):
date,description,amount
2025-01-05,Coffee at Starbucks,4.50
2025-01-10,Amazon Prime subscription,14.99
2025-01-15,Grocery run Walmart,-200.00
Plain text (set EXPENSES_TEXT):
Jan 5 Coffee 4.50
Amazon Prime 14.99
Jan 15 Groceries Walmart 200
Uber ride 22.50
ð Security
Runs entirely locally. No data transmitted. Your financial data stays on your machine.
Step 1 â Install
pip3 install rich --break-system-packages --quiet
Step 2 â Analyse your expenses
import os, re, json, csv
from datetime import datetime, date
from collections import defaultdict
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
console = Console()
EXPENSES_FILE = os.environ.get("EXPENSES_FILE", "").strip()
EXPENSES_TEXT = os.environ.get("EXPENSES_TEXT", "").strip()
BUDGET_RAW = os.environ.get("BUDGET_JSON", "").strip()
CURRENCY = os.environ.get("CURRENCY", "USD").upper()
REPORT_MONTH = os.environ.get("REPORT_MONTH", "").strip()
TODAY = date.today()
# ââ Category rules âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
CATEGORIES = {
"Food & Dining": ["coffee", "starbucks", "restaurant", "pizza", "burger", "sushi", "cafe",
"dining", "food", "doordash", "uber eats", "grubhub", "mcdonalds",
"chipotle", "subway", "lunch", "dinner", "breakfast", "grocery", "groceries",
"walmart", "whole foods", "trader joe", "supermarket"],
"Transport": ["uber", "lyft", "taxi", "gas", "fuel", "parking", "transit", "metro",
"bus", "train", "amtrak", "airline", "flight", "airfare", "car rental",
"toll", "mileage", "petrol"],
"Shopping": ["amazon", "ebay", "etsy", "target", "bestbuy", "best buy", "ikea",
"clothing", "apparel", "shoes", "fashion", "zara", "h&m", "gap",
"nordstrom", "mall", "store", "shop"],
"Subscriptions": ["netflix", "spotify", "hulu", "disney", "apple music", "youtube",
"prime", "subscription", "membership", "monthly", "annual fee",
"software", "saas", "adobe", "microsoft", "google"],
"Utilities": ["electric", "electricity", "water", "gas bill", "internet", "phone",
"mobile", "cellular", "at&t", "verizon", "comcast", "xfinity",
"hydro", "utility", "bill"],
"Health": ["pharmacy", "doctor", "dentist", "medical", "hospital", "clinic",
"prescription", "medicine", "health", "gym", "fitness", "yoga",
"cvs", "walgreens", "rite aid"],
"Entertainment": ["movie", "cinema", "theatre", "concert", "event", "ticket",
"game", "gaming", "steam", "playstation", "xbox", "nintendo",
"book", "kindle", "audible", "museum", "sport"],
"Travel": ["hotel", "airbnb", "hostel", "motel", "resort", "booking",
"expedia", "trip", "vacation", "holiday", "travel", "tour"],
"Education": ["course", "udemy", "coursera", "tuition", "textbook", "school",
"university", "college", "training", "workshop", "class", "lesson"],
"Home": ["rent", "mortgage", "furniture", "home depot", "lowes", "hardware",
"repair", "maintenance", "cleaning", "plumber", "electrician"],
"Insurance": ["insurance", "premium", "policy", "coverage", "geico", "allstate",
"progressive", "state farm"],
"Business": ["invoice", "client", "freelance", "office", "supplies", "coworking",
"conference", "advertising", "marketing", "domain", "hosting"],
"Personal Care": ["salon", "haircut", "barber", "spa", "beauty", "cosmetics",
"skincare", "makeup", "nails"],
"Income / Credit": [], # used for negative amounts
}
def categorise(description: str, amount: float) -> str:
if amount < 0:
return "Income / Credit"
desc_low = description.lower()
for cat, keywords in CATEGORIES.items():
if cat == "Income / Credit":
continue
for kw in keywords:
if kw in desc_low:
return cat
return "Other"
# ââ Parse amount string âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
def parse_amount(raw: str) -> float | None:
raw = raw.strip().lstrip("$£â¬").replace(",", "")
try:
return float(raw)
except ValueError:
return None
# ââ Parse date string âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
MONTH_MAP = {"jan":1,"feb":2,"mar":3,"apr":4,"may":5,"jun":6,
"jul":7,"aug":8,"sep":9,"oct":10,"nov":11,"dec":12}
def parse_date(raw: str) -> date | None:
raw = raw.strip()
# Try ISO format first
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%m-%d-%Y", "%Y/%m/%d"):
try:
return datetime.strptime(raw, fmt).date()
except ValueError:
pass
# Try "Jan 5" or "Jan 5 2025"
m = re.match(r"([A-Za-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?", raw)
if m:
mon_str = m.group(1)[:3].lower()
mon = MONTH_MAP.get(mon_str)
if mon:
day = int(m.group(2))
yr = int(m.group(3)) if m.group(3) else TODAY.year
try:
return date(yr, mon, day)
except ValueError:
pass
return None
# ââ Load transactions âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
transactions = []
if EXPENSES_FILE:
if not os.path.exists(EXPENSES_FILE):
console.print(f"[red]â File not found: {EXPENSES_FILE}[/red]")
raise SystemExit(1)
with open(EXPENSES_FILE, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
headers = [h.lower().strip() for h in (reader.fieldnames or [])]
amt_col = next((h for h in headers if "amount" in h or "amt" in h or "cost" in h or "price" in h), None)
date_col = next((h for h in headers if "date" in h or "day" in h), None)
desc_col = next((h for h in headers if "desc" in h or "name" in h or "memo" in h or "narration" in h or "payee" in h), None)
if not amt_col:
console.print(f"[red]â CSV must have an 'amount' column. Found: {headers}[/red]")
raise SystemExit(1)
for row in reader:
raw_keys = {k.lower().strip(): v for k, v in row.items()}
raw_amt = raw_keys.get(amt_col, "0")
amt = parse_amount(raw_amt)
if amt is None:
continue
raw_desc = raw_keys.get(desc_col, "Unknown") if desc_col else "Unknown"
raw_date = raw_keys.get(date_col, "") if date_col else ""
txn_date = parse_date(raw_date) or TODAY
transactions.append({"date": txn_date, "description": raw_desc.strip(), "amount": amt})
elif EXPENSES_TEXT:
for line in EXPENSES_TEXT.strip().splitlines():
line = line.strip()
if not line:
continue
# Extract amount (last number-like token)
tokens = line.split()
amt = None
for tok in reversed(tokens):
amt = parse_amount(tok)
if amt is not None:
break
if amt is None:
continue
# Try to extract date from beginning
txn_date = None
desc_start = 0
if len(tokens) >= 2:
date_try = parse_date(tokens[0] + " " + tokens[1])
if date_try:
txn_date = date_try
desc_start = 2
else:
date_try = parse_date(tokens[0])
if date_try:
txn_date = date_try
desc_start = 1
# Description is everything between date and amount
desc_tokens = [t for t in tokens[desc_start:] if parse_amount(t) != amt]
description = " ".join(desc_tokens) or "Unknown"
transactions.append({"date": txn_date or TODAY, "description": description, "amount": amt})
else:
# Demo data so users can see the skill working
console.print("[yellow]â¹ï¸ No EXPENSES_FILE or EXPENSES_TEXT set â running with demo data.[/yellow]")
console.print("[dim]Set EXPENSES_FILE=path/to/transactions.csv or EXPENSES_TEXT='...' to use your own data.[/dim]\n")
demo = [
("2025-01-05", "Starbucks coffee", 5.50),
("2025-01-08", "Uber ride downtown", 18.30),
("2025-01-10", "Netflix monthly subscription", 15.99),
("2025-01-12", "Grocery run Walmart", 87.45),
("2025-01-14", "Amazon order", 34.99),
("2025-01-18", "Restaurant dinner", 62.00),
("2025-01-20", "Gas station fuel", 55.00),
("2025-01-22", "Spotify premium", 9.99),
("2025-01-25", "Pharmacy CVS", 22.10),
("2025-01-28", "Gym membership", 45.00),
("2025-02-02", "Coffee shop", 4.80),
("2025-02-05", "Electric bill", 110.00),
("2025-02-08", "Uber eats delivery", 28.50),
("2025-02-12", "Whole Foods groceries", 93.20),
("2025-02-15", "Client payment", -500.00),
("2025-02-18", "Amazon Prime annual", 139.00),
("2025-02-20", "Doctor visit copay", 30.00),
("2025-02-25", "Movie tickets", 28.00),
]
for d, desc, amt in demo:
transactions.append({"date": parse_date(d) or TODAY, "description": desc, "amount": amt})
# ââ Filter by month âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
if REPORT_MONTH:
try:
filter_dt = datetime.strptime(REPORT_MONTH, "%Y-%m")
transactions = [t for t in transactions
if t["date"].year == filter_dt.year and t["date"].month == filter_dt.month]
if not transactions:
console.print(f"[yellow]â ï¸ No transactions found for {REPORT_MONTH}[/yellow]")
raise SystemExit(0)
except ValueError:
console.print("[red]â REPORT_MONTH must be YYYY-MM format (e.g. 2025-01)[/red]")
raise SystemExit(1)
# ââ Categorise all transactions âââââââââââââââââââââââââââââââââââââââââââââââ
for t in transactions:
t["category"] = categorise(t["description"], t["amount"])
# ââ Parse budget ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
budget = {}
if BUDGET_RAW:
try:
budget = {k.title(): float(v) for k, v in json.loads(BUDGET_RAW).items()}
except (json.JSONDecodeError, ValueError):
console.print("[yellow]â ï¸ BUDGET_JSON is not valid JSON â budget comparison skipped.[/yellow]")
# ââ Aggregate: totals by category ââââââââââââââââââââââââââââââââââââââââââââ
cat_totals: dict[str, float] = defaultdict(float)
for t in transactions:
cat_totals[t["category"]] += t["amount"]
expenses_only = {k: v for k, v in cat_totals.items() if v > 0}
total_spend = sum(expenses_only.values())
credits = abs(cat_totals.get("Income / Credit", 0))
# ââ Aggregate: monthly trends âââââââââââââââââââââââââââââââââââââââââââââââââ
monthly: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
for t in transactions:
if t["amount"] > 0:
mo = t["date"].strftime("%Y-%m")
monthly[mo][t["category"]] += t["amount"]
months_sorted = sorted(monthly.keys())
all_cats = sorted(expenses_only.keys())
# ââ Display header ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
console.print()
console.print(Panel.fit(
f"[bold green]ð° Plutus â Expense Report[/bold green]\n"
f"Transactions: [yellow]{len(transactions)}[/yellow] | "
f"Total spend: [red]{CURRENCY} {total_spend:,.2f}[/red] | "
f"Credits: [green]{CURRENCY} {credits:,.2f}[/green]",
border_style="green"
))
# ââ Display: Category totals ââââââââââââââââââââââââââââââââââââââââââââââââââ
console.print()
cat_table = Table(
title=f"Spend by Category",
box=box.ROUNDED, border_style="green"
)
cat_table.add_column("Category", style="cyan", width=20)
cat_table.add_column(f"Total ({CURRENCY})", style="red", justify="right", width=14)
cat_table.add_column("% of Total", style="yellow", justify="right", width=12)
cat_table.add_column("Budget", style="dim", justify="right", width=12)
cat_table.add_column("Status", style="white", width=12)
for cat, total in sorted(expenses_only.items(), key=lambda x: -x[1]):
pct = (total / total_spend * 100) if total_spend else 0
bgt = budget.get(cat, None)
if bgt:
status = "[green]â
OK[/green]" if total <= bgt else f"[red]â +{CURRENCY}{total-bgt:.0f}[/red]"
bgt_str = f"{CURRENCY}{bgt:,.0f}"
else:
status = ""
bgt_str = "â"
cat_table.add_row(cat, f"{total:,.2f}", f"{pct:.1f}%", bgt_str, status)
if credits:
cat_table.add_row("â" * 18, "â" * 10, "", "", "")
cat_table.add_row("[green]Income / Credits[/green]", f"[green]-{credits:,.2f}[/green]", "", "", "")
console.print(cat_table)
# ââ Display: Monthly trends âââââââââââââââââââââââââââââââââââââââââââââââââââ
if len(months_sorted) > 1:
console.print()
trend_table = Table(
title="Monthly Spend Trends",
box=box.SIMPLE, border_style="blue"
)
trend_table.add_column("Month", style="cyan", width=10)
trend_table.add_column(f"Total ({CURRENCY})", style="red", justify="right", width=12)
for cat in all_cats[:6]: # show top 6 categories in trends
trend_table.add_column(cat[:12], justify="right", width=12)
for mo in months_sorted:
mo_total = sum(monthly[mo].values())
row = [mo, f"{mo_total:,.2f}"]
for cat in all_cats[:6]:
v = monthly[mo].get(cat, 0)
row.append(f"{v:,.2f}" if v else "â")
trend_table.add_row(*row)
console.print(trend_table)
# ââ Display: Top transactions âââââââââââââââââââââââââââââââââââââââââââââââââ
console.print()
top_txns = sorted([t for t in transactions if t["amount"] > 0], key=lambda x: -x["amount"])[:10]
top_table = Table(title="Top 10 Transactions", box=box.ROUNDED, border_style="yellow")
top_table.add_column("Date", style="dim", width=12)
top_table.add_column("Description", style="white", width=30)
top_table.add_column("Category", style="cyan", width=20)
top_table.add_column(f"Amount ({CURRENCY})", style="red", justify="right", width=12)
for t in top_txns:
top_table.add_row(
t["date"].strftime("%b %d, %Y"),
t["description"][:28],
t["category"],
f"{t['amount']:,.2f}"
)
console.print(top_table)
# ââ Save outputs ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
slug = TODAY,strftime("%Y-%m")
if REPORT_MONTH:
slug = REPORT_MONTH
md_path = f"expense_report_{slug}.md"
csv_path = f"expense_summary_{slug}.csv"
with open(md_path, "w", encoding="utf-8") as f:
f.write(f"# ð° Expense Report â {slug}\n\n")
f.write(f"**Total spend:** {CURRENCY} {total_spend:,.2f} ")
if credits:
f.write(f"**Credits:** {CURRENCY} {credits:,.2f} ")
f.write(f"**Transactions:** {len(transactions)}\n\n")
f.write("## By Category\n\n| Category | Amount | % |\n|----------|--------|---|\n")
for cat, total in sorted(expenses_only.items(), key=lambda x: -x[1]):
pct = (total / total_spend * 100) if total_spend else 0
f.write(f"| {cat} | {CURRENCY} {total:,.2f} | {pct:.1f}% |\n")
f.write("\n## All Transactions\n\n| Date | Description | Category | Amount |\n|------|-------------|----------|--------|\n")
for t in sorted(transactions, key=lambda x: x["date"]):
sign = "-" if t["amount"] < 0 else ""
f.write(f"| {t['date'].strftime('%b %d')} | {t['description']} | {t['category']} | {sign}{CURRENCY}{abs(t['amount']):,.2f} |\n")
with open(csv_path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["category", f"total_{CURRENCY.lower()}", "pct_of_spend"])
for cat, total in sorted(expenses_only.items(), key=lambda x: -x[1]):
pct = (total / total_spend * 100) if total_spend else 0
writer.writerow([cat, f"{total:.2f}", f"{pct:.1f}"])
console.print()
console.print(Panel(
f"[green]â
Report complete![/green]\n\n"
f"ð [cyan]{md_path}[/cyan] â full markdown report\n"
f"ð [cyan]{csv_path}[/cyan] â category summary CSV\n\n"
f"[dim]Set EXPENSES_FILE or EXPENSES_TEXT to analyse your own transactions.[/dim]",
title="[bold]ð¤ Exports[/bold]",
border_style="green"
))