Plutus — God of Wealth

Plutus — God of Wealth. Parse receipts and expense descriptions into a categorised expense report. Paste raw text, a list, or point to a CSV of transactions and get back a clean report with categories, totals, monthly trends, and a budget vs actual comparison. Works for personal finances, freelancers, or small business bookkeeping.

Audits

Pass

Install

openclaw skills install plutus

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"
))