caseyg-analyze-spending
Analyze Spending with LunchMoney
Analyze your finances, identify spending patterns, review budgets, and find subscriptions you might want to cancel using data from LunchMoney.
Trigger Phrases
/analyze-spending- "analyze my spending"
- "review my finances"
- "find subscriptions to cancel"
- "audit my recurring expenses"
- "how much did I spend on [category]?"
- "budget review for [period]"
- "where is my money going?"
Prerequisites
- LunchMoney Account: Active subscription with API access
- API Key: Generate at https://my.lunchmoney.app/developers
- 1Password Storage: Store the API key in 1Password:
- Item name:
Lunchmoney - Field:
API Key
- Item name:
File Structure
skills/analyze-spending/
├── SKILL.md # This file
├── assets/
│ ├── MEMORY.md # Template for user preferences
│ └── subscription-audit-template.md # Template for categorized report
└── data/ # Runtime data (gitignored)
├── MEMORY.md # User preferences & decisions (copy from assets/)
├── cache/ # Cached API responses
│ ├── user.json # User info (refreshed weekly)
│ ├── categories.json # Categories (refreshed weekly)
│ ├── recurring_items.json # Subscriptions (refreshed daily)
│ └── transactions/ # Transaction data by month
│ ├── 2025-12.json
│ └── 2026-01.json
├── cache_meta.json # Cache timestamps
└── reports/ # Generated markdown reports
├── 2026-01-19_spending-overview.md
└── 2026-01-19_subscription-audit.md
Memory & Preferences
The MEMORY.md file stores user preferences, decisions, and learned patterns across sessions.
Initialize Memory
SKILL_DIR="skills/analyze-spending"
MEMORY_FILE="${SKILL_DIR}/data/MEMORY.md"
MEMORY_TEMPLATE="${SKILL_DIR}/assets/MEMORY.md"
# Create data directory and copy template if MEMORY.md doesn't exist
mkdir -p "${SKILL_DIR}/data"
if [ ! -f "$MEMORY_FILE" ]; then
cp "$MEMORY_TEMPLATE" "$MEMORY_FILE"
echo "Created MEMORY.md from template"
fi
Reading Preferences from Memory
Extract YAML configuration blocks from MEMORY.md:
# Extract essential subscriptions (won't flag for cancellation)
ESSENTIAL_SUBS=$(sed -n '/^essential_subscriptions:/,/^[a-z]/p' "$MEMORY_FILE" | \
grep -E '^\s+-\s+payee:' | sed 's/.*payee:\s*"\(.*\)"/\1/')
# Extract excluded categories
EXCLUDED_CATS=$(sed -n '/^excluded_categories:/,/^[a-z]/p' "$MEMORY_FILE" | \
grep -E '^\s+-\s+"' | sed 's/.*"\(.*\)"/\1/')
# Extract watchlist
WATCHLIST=$(sed -n '/^watchlist:/,/^[a-z]/p' "$MEMORY_FILE" | \
grep -E '^\s+-\s+payee:' | sed 's/.*payee:\s*"\(.*\)"/\1/')
Updating Memory After Decisions
When the user makes a decision about a subscription, append to the Session History:
# Add a decision to memory
add_decision() {
local date="$1"
local subscription="$2"
local decision="$3"
local reason="$4"
local savings="$5"
cat >> "$MEMORY_FILE" << EOF
#### ${date}
- **${subscription}**: ${decision}
- Reason: ${reason}
- Annual savings: \$${savings}
EOF
}
# Example: User decided to cancel a subscription
add_decision "2026-01-19" "Crunch Gym" "cancel" "Keeping Chelsea Piers instead" "1053"
Adding to Watchlist
add_to_watchlist() {
local payee="$1"
local reason="$2"
local review_date=$(date -v+30d +%Y-%m-%d)
# This would need more sophisticated YAML editing
# For now, prompt user to manually add:
echo "Add to watchlist section in MEMORY.md:"
echo " - payee: \"${payee}\""
echo " added: $(date +%Y-%m-%d)"
echo " review_date: ${review_date}"
echo " reason: \"${reason}\""
}
Using Memory in Analysis
When analyzing, filter based on preferences:
# Filter out essential subscriptions from cancellation suggestions
filter_subscriptions() {
jq --argjson essential "$(echo "$ESSENTIAL_SUBS" | jq -R . | jq -s .)" '
map(select(.payee as $p | $essential | index($p) | not))
'
}
# Exclude certain categories from spending totals
filter_transactions() {
jq --argjson excluded "$(echo "$EXCLUDED_CATS" | jq -R . | jq -s .)" '
.transactions | map(select(.category_name as $c | $excluded | index($c) | not))
'
}
Workflow
0. Interview Mode (Always On)
The skill includes an interactive interview to validate subscription data and make decisions. This is critical because:
- LunchMoney's
transactions_within_rangefield is often unreliable - Users forget what's cancelled vs still active
- Categorization may need user confirmation
Interview Categories (ask about each in order):
- Fitness - Gym memberships, fitness apps
- Streaming - Video, music, audiobooks
- AI/Dev Tools - AI subscriptions, hosting, developer tools
- Reading/News - RSS, read-later, newsletters
- Productivity - Software subscriptions
- Memberships - Patreon, donations, co-ops
Interview Questions Pattern:
Use AskUserQuestion tool with:
- Show actual charges found from 12 months of transactions
- Group by category with totals (monthly/annual)
- Include "Last Charged" date and status indicators (✅/⚠️/🔴/❌)
- Distinguish cancelled (❌) from irregular (🔴 Xd ago)
- Offer actionable choices: Keep all, Cancel all, Review each, etc.
Key Learnings to Apply:
- Use 12-month window to catch annual subscriptions and show real activity
- Always cross-check recurring_items with actual transactions using normalized payee matching
- Show "Last Charged: Dec 2025" not confusing "Missed 3" status
- Price variations are normal (e.g., Starlink $120 → $5) - match by name not amount
- Ask follow-up questions based on responses
- Record all decisions in MEMORY.md
After Interview:
- Update MEMORY.md with decisions
- Generate action items list
- Calculate estimated savings
- Set review dates for watchlist items
1. Initialize Data Directory
SKILL_DIR="skills/analyze-spending"
DATA_DIR="${SKILL_DIR}/data"
CACHE_DIR="${DATA_DIR}/cache"
REPORTS_DIR="${DATA_DIR}/reports"
TRANSACTIONS_DIR="${CACHE_DIR}/transactions"
mkdir -p "${CACHE_DIR}" "${REPORTS_DIR}" "${TRANSACTIONS_DIR}"
# Initialize MEMORY.md if needed
MEMORY_FILE="${DATA_DIR}/MEMORY.md"
if [ ! -f "$MEMORY_FILE" ]; then
cp "${SKILL_DIR}/assets/MEMORY.md" "$MEMORY_FILE"
fi
2. Retrieve API Key
LUNCHMONEY_API_KEY=$(op item get "Lunchmoney" --fields "API Key" --reveal)
3. Check Cache Validity
Before fetching, check if cached data is still fresh:
CACHE_META="${CACHE_DIR}/cache_meta.json"
# Initialize cache meta if it doesn't exist
if [ ! -f "$CACHE_META" ]; then
echo '{}' > "$CACHE_META"
fi
# Check if cache is fresh (returns "true" or "false")
is_cache_fresh() {
local cache_key="$1"
local max_age_hours="$2"
local last_fetch=$(jq -r ".${cache_key} // 0" "$CACHE_META")
local now=$(date +%s)
local age_hours=$(( (now - last_fetch) / 3600 ))
[ "$age_hours" -lt "$max_age_hours" ] && echo "true" || echo "false"
}
# Update cache timestamp
update_cache_meta() {
local cache_key="$1"
local now=$(date +%s)
local tmp=$(mktemp)
jq ".${cache_key} = ${now}" "$CACHE_META" > "$tmp" && mv "$tmp" "$CACHE_META"
}
Cache freshness thresholds:
| Data Type | Max Age | Rationale |
|---|---|---|
| user | 168 hours (7 days) | Rarely changes |
| categories | 168 hours (7 days) | Rarely changes |
| recurring_items | 24 hours | May change daily |
| transactions (current month) | 4 hours | Active updates |
| transactions (past months) | 168 hours | Historical, stable |
4. Fetch and Cache User Info
USER_CACHE="${CACHE_DIR}/user.json"
if [ "$(is_cache_fresh user 168)" = "false" ] || [ ! -f "$USER_CACHE" ]; then
curl -s "https://dev.lunchmoney.app/v1/me" \
-H "Authorization: Bearer ${LUNCHMONEY_API_KEY}" \
> "$USER_CACHE"
update_cache_meta "user"
echo "Fetched fresh user data"
else
echo "Using cached user data"
fi
5. Fetch and Cache Categories
CATEGORIES_CACHE="${CACHE_DIR}/categories.json"
if [ "$(is_cache_fresh categories 168)" = "false" ] || [ ! -f "$CATEGORIES_CACHE" ]; then
curl -s "https://dev.lunchmoney.app/v1/categories" \
-H "Authorization: Bearer ${LUNCHMONEY_API_KEY}" \
> "$CATEGORIES_CACHE"
update_cache_meta "categories"
echo "Fetched fresh categories"
else
echo "Using cached categories"
fi
6. Fetch and Cache Transactions
Transactions are cached per-month to enable efficient incremental updates:
fetch_transactions_for_month() {
local year_month="$1" # Format: YYYY-MM
local start_date="${year_month}-01"
local end_date=$(date -j -f "%Y-%m-%d" "${start_date}" -v+1m -v-1d +%Y-%m-%d 2>/dev/null || \
date -d "${start_date} +1 month -1 day" +%Y-%m-%d)
local cache_file="${TRANSACTIONS_DIR}/${year_month}.json"
local cache_key="transactions_${year_month//-/_}"
# Current month: refresh every 4 hours; past months: refresh weekly
local current_month=$(date +%Y-%m)
local max_age=168
[ "$year_month" = "$current_month" ] && max_age=4
if [ "$(is_cache_fresh $cache_key $max_age)" = "false" ] || [ ! -f "$cache_file" ]; then
curl -s "https://dev.lunchmoney.app/v1/transactions?start_date=${start_date}&end_date=${end_date}&debit_as_negative=true&limit=1000" \
-H "Authorization: Bearer ${LUNCHMONEY_API_KEY}" \
> "$cache_file"
update_cache_meta "$cache_key"
echo "Fetched transactions for ${year_month}"
else
echo "Using cached transactions for ${year_month}"
fi
}
# Fetch for a date range (fetches all months in range)
START_DATE="2025-12-01"
END_DATE="2026-01-31"
# Generate list of months in range and fetch each
current="$START_DATE"
while [ "$current" \< "$END_DATE" ] || [ "$current" = "$END_DATE" ]; do
month=$(echo "$current" | cut -d- -f1-2)
fetch_transactions_for_month "$month"
current=$(date -j -f "%Y-%m-%d" "${month}-01" -v+1m +%Y-%m-%d 2>/dev/null || \
date -d "${month}-01 +1 month" +%Y-%m-%d)
done
7. Fetch and Cache Recurring Items
RECURRING_CACHE="${CACHE_DIR}/recurring_items.json"
START_DATE=$(date -v-60d +%Y-%m-%d) # 60 days back for context
END_DATE=$(date +%Y-%m-%d)
if [ "$(is_cache_fresh recurring 24)" = "false" ] || [ ! -f "$RECURRING_CACHE" ]; then
curl -s "https://dev.lunchmoney.app/v1/recurring_items?start_date=${START_DATE}&end_date=${END_DATE}&debit_as_negative=true" \
-H "Authorization: Bearer ${LUNCHMONEY_API_KEY}" \
> "$RECURRING_CACHE"
update_cache_meta "recurring"
echo "Fetched fresh recurring items"
else
echo "Using cached recurring items"
fi
8. Load Cached Data for Analysis
# Read from cache files
USER_DATA=$(cat "$USER_CACHE")
CATEGORIES=$(cat "$CATEGORIES_CACHE")
RECURRING=$(cat "$RECURRING_CACHE")
# Merge transaction files for date range
TRANSACTIONS=$(jq -s '
map(.transactions // []) |
add |
sort_by(.date) |
reverse
' ${TRANSACTIONS_DIR}/*.json)
Report Generation
Generate Spending Overview Report
REPORT_DATE=$(date +%Y-%m-%d)
REPORT_FILE="${REPORTS_DIR}/${REPORT_DATE}_spending-overview.md"
generate_spending_report() {
local start_date="$1"
local end_date="$2"
local period_name="$3"
cat > "$REPORT_FILE" << EOF
# Spending Overview: ${period_name}
*Generated: $(date '+%Y-%m-%d %H:%M')*
*Period: ${start_date} to ${end_date}*
## Summary
| Metric | Amount |
|--------|--------|
| Income | \$${TOTAL_INCOME} |
| Expenses | \$${TOTAL_EXPENSES} |
| **Net** | **\$${NET}** |
## Spending by Category
| Category | Amount | % |
|----------|--------|---|
$(echo "$CATEGORY_BREAKDOWN")
## Top Merchants
| Merchant | Amount | # Txns |
|----------|--------|--------|
$(echo "$TOP_MERCHANTS")
---
*Data cached at: $(jq -r '.transactions_2026_01 // "N/A" | tonumber | strftime("%Y-%m-%d %H:%M")' "$CACHE_META" 2>/dev/null || echo "N/A")*
EOF
echo "Report saved to: $REPORT_FILE"
}
Generate Subscription Audit Report (Template-Based)
The subscription audit uses a fill-in-the-blanks template at assets/subscription-audit-template.md. This approach:
- Categorizes subscriptions into meaningful groups
- Provides actionable recommendations at the top
- Analyzes 12 months of transaction data (default) for comprehensive coverage
- Cross-references recurring_items with actual transactions
- Shows "Last Charged" date instead of unreliable "Missed N" status
- Detects duplicate services and truly inactive subscriptions
Template placeholders: {{TOTAL_MONTHLY}}, {{CAT_STREAMING_ROWS}}, etc.
Subscription Categories:
| Category | Keywords for Matching |
|---|---|
| 🏠 Housing & Utilities | rent, house fee, loan, verizon, starlink |
| 📺 Streaming | hulu, hbo, prime video, paramount, disney, spotify |
| 💪 Fitness | gym, fitness, crunch |
| 🧠 AI & Learning | chatgpt, jasper, execute program |
| 💻 Developer Tools | github, linode, digitalocean, ngrok |
| 📱 Productivity | obsidian, notion, adobe, zapier |
| 📰 News & Media | substack, readwise, feedbin, newsblur |
| 💾 Storage | backblaze, google storage, icloud |
| 🎁 Memberships | patreon, rei, open source |
Python-based generation (avoids jq != escaping issues in bash):
import json
from pathlib import Path
# Load data
with open("data/cache/recurring_items.json") as f:
items = json.load(f)
with open("assets/subscription-audit-template.md") as f:
template = f.read()
# Categorize and calculate
active_items = [i for i in items if not i.get("exclude_from_totals", False)]
def annual_cost(item):
amount = float(item.get("amount", 0) or 0)
cadence = item.get("cadence", "monthly")
multipliers = {
"yearly": 1, "monthly": 12, "weekly": 52,
"biweekly": 26, "quarterly": 4
}
return amount * multipliers.get(cadence, 12)
# Generate recommendations based on:
# - Category overlap (multiple streaming, multiple gyms)
# - Inactive subscriptions (no transactions_within_range)
# - Duplicate payee names
# Fill template placeholders
output = template.replace("{{TOTAL_MONTHLY}}", f"{total:.2f}")
# ... fill all placeholders
Path("data/reports").mkdir(exist_ok=True)
with open(f"data/reports/{date}_subscription-audit.md", "w") as f:
f.write(output)
Fetch 12 months of data for analysis (recommended default):
# Fetch 12 months of transactions for comprehensive analysis
# This catches annual subscriptions and provides accurate "Last Charged" dates
for i in {11..0}; do
MONTH=$(date -v-${i}m +%Y-%m)
fetch_transactions_for_month "$MONTH"
done
# Update recurring items with 90-day window (for LunchMoney's own tracking)
START_DATE=$(date -v-90d +%Y-%m-%d)
END_DATE=$(date +%Y-%m-%d)
curl -s "https://dev.lunchmoney.app/v1/recurring_items?start_date=${START_DATE}&end_date=${END_DATE}" \
-H "Authorization: Bearer ${LUNCHMONEY_API_KEY}" \
> "$RECURRING_CACHE"
Why 12 months?
- Catches annual subscriptions (GitHub, Backblaze, etc.)
- Shows true "Last Charged" date instead of "Missed N"
- Identifies cancelled vs just-irregular subscriptions
- Price changes are visible (e.g., Starlink $120 → $5)
Analysis Using Cached Data
Find ACTUALLY Active Subscriptions (Cross-Reference Method)
Important: Don't rely solely on recurring_items.transactions_within_range - it's often empty even when charges exist. Cross-reference with actual transactions using normalized payee matching:
import json
import re
from collections import defaultdict
from pathlib import Path
# Payee name normalization - critical for matching variations
def normalize(name):
"""Normalize payee name for matching (case-insensitive, strip suffixes)"""
if not name:
return ""
name = name.lower().strip()
# Remove common suffixes/prefixes
name = re.sub(r'\s*(inc\.?|llc|ltd|co\.?|corp\.?|subscription|membership|payment|charge|premium)\s*', ' ', name)
# Remove special characters
name = re.sub(r'[^a-z0-9\s]', '', name)
# Collapse whitespace
name = re.sub(r'\s+', ' ', name).strip()
return name
def get_keywords(name):
"""Extract brand keywords for fuzzy matching"""
# E.g., "STARLINK INTERNET LLC" → ["starlink", "internet"]
normalized = normalize(name)
return set(normalized.split())
# Load 12 months of transactions from cache
transactions = []
for month_file in Path("data/cache/transactions").glob("*.json"):
with open(month_file) as f:
transactions.extend(json.load(f).get("transactions", []))
# Find transactions matching a recurring item (flexible matching)
def find_transactions_for_payee(target_payee, original_name=None):
"""Match transactions by normalized name or keywords"""
target_norm = normalize(target_payee)
target_keywords = get_keywords(target_payee)
matches = []
for txn in transactions:
payee = txn.get("payee", "")
payee_norm = normalize(payee)
# 1. Direct normalized match
if target_norm and target_norm in payee_norm:
matches.append(txn)
continue
# 2. Reverse containment
if payee_norm and payee_norm in target_norm:
matches.append(txn)
continue
# 3. Keyword overlap (for brand names)
payee_keywords = get_keywords(payee)
if target_keywords & payee_keywords: # Set intersection
matches.append(txn)
continue
return matches
# Build subscription report with "Last Charged" dates
for item in recurring_items:
payee = item.get("payee", "Unknown")
txns = find_transactions_for_payee(payee, item.get("original_name"))
if txns:
# Sort by date descending
txns.sort(key=lambda t: t.get("date", ""), reverse=True)
last_charge = txns[0].get("date") # e.g., "2025-12-22"
charge_count = len(txns)
monthly_avg = sum(abs(float(t.get("amount", 0))) for t in txns) / 12
print(f"{payee}: ${monthly_avg:.2f}/mo | Last: {last_charge} | {charge_count} charges")
else:
print(f"{payee}: ❌ No charges found in 12 months (likely cancelled)")
Key matching strategies:
- Name normalization: "STARLINK INTERNET LLC" → "starlink internet"
- Keyword extraction: Matches "starlink" even if merchant name varies
- Price-agnostic: Same vendor, different amounts still match
- Bidirectional containment: "HBO Max" matches "HBO MAX SUBSCRIPTION"
Status indicators (in reports):
- ✅ Active: Last charged within 45 days
- ⚠️ Stale: Last charge 45-90 days ago
- 🔴 Xd ago: Shows days since last charge (e.g., "🔴 136d ago")
- ❌ Cancelled: No charges found in 12 months
Analyze Spending by Category
jq -r '
.transactions |
group_by(.category_name) |
map({
category: (.[0].category_name // "Uncategorized"),
total: (map(.amount | tonumber | if . < 0 then . * -1 else 0 end) | add),
count: length
}) |
sort_by(-.total) |
.[:15]
' "${TRANSACTIONS_DIR}/2026-01.json"
Calculate Income vs Expenses
jq -r '
.transactions |
[
(map(select(.amount | tonumber | . < 0) | .amount | tonumber | . * -1) | add),
(map(select(.amount | tonumber | . > 0) | .amount | tonumber) | add)
] |
{expenses: .[0], income: .[1], net: (.[1] - .[0])}
' "${TRANSACTIONS_DIR}/2026-01.json"
Find Top Merchants
jq -r '
.transactions |
map(select(.amount | tonumber | . < 0)) |
group_by(.payee) |
map({
payee: .[0].payee,
total: (map(.amount | tonumber | . * -1) | add),
count: length
}) |
sort_by(-.total) |
.[:10]
' "${TRANSACTIONS_DIR}/2026-01.json"
Subscription Annual Cost Analysis
jq '
def annual_cost(amount; cadence):
if cadence == "weekly" then amount * 52
elif cadence == "biweekly" then amount * 26
elif cadence == "twice a month" then amount * 24
elif cadence == "monthly" then amount * 12
elif cadence == "every 3 months" then amount * 4
elif cadence == "every 4 months" then amount * 3
elif cadence == "twice a year" then amount * 2
elif cadence == "yearly" then amount
else amount * 12
end;
map(select(.exclude_from_totals != true)) |
{
total_monthly: (map(if .cadence == "monthly" then (.amount | tonumber) else 0 end) | add),
total_annual: (map(annual_cost((.amount | tonumber); .cadence)) | add),
count: length
}
' "$RECURRING_CACHE"
Force Refresh Cache
To force a fresh fetch of all data:
# Clear all cache
rm -rf "${CACHE_DIR}"/*
echo '{}' > "${CACHE_DIR}/cache_meta.json"
mkdir -p "${TRANSACTIONS_DIR}"
# Or clear specific cache
rm "${CACHE_DIR}/recurring_items.json"
jq 'del(.recurring)' "$CACHE_META" > tmp && mv tmp "$CACHE_META"
Viewing Past Reports
# List all generated reports
ls -la "${REPORTS_DIR}"
# View most recent spending report
cat "${REPORTS_DIR}/$(ls -t ${REPORTS_DIR}/*spending* | head -1)"
# View most recent subscription audit
cat "${REPORTS_DIR}/$(ls -t ${REPORTS_DIR}/*subscription* | head -1)"
Cadence Calculation
Convert LunchMoney cadence to annual cost:
| Cadence | Multiplier |
|---|---|
| weekly | × 52 |
| biweekly | × 26 |
| twice a month | × 24 |
| monthly | × 12 |
| every 3 months | × 4 |
| every 4 months | × 3 |
| twice a year | × 2 |
| yearly | × 1 |
Error Handling
| Error | Cause | Solution |
|---|---|---|
| 401 Unauthorized | Invalid/expired API key | Generate new key at lunchmoney.app/developers |
| 404 Not Found | Invalid endpoint | Check API URL |
| Empty transactions | No data for period | Verify date range has transactions |
| 1Password error | Item not found | Ensure "Lunchmoney" item exists with "API Key" field |
| Stale cache | Data not refreshing | Check cache_meta.json timestamps, force refresh if needed |
API Reference
- Base URL:
https://dev.lunchmoney.app/v1 - Authentication: Bearer token in Authorization header
- Documentation: https://lunchmoney.dev/
- Rate Limits: Standard API rate limits apply
Example Session
User: /analyze-spending
Claude: I'll analyze your spending. Let me check the cache and cross-reference
actual transactions (12-month window for comprehensive coverage)...
[Fetching 12 months of transactions: Feb 2025 - Jan 2026]
[Cross-referencing recurring_items with actual transaction data]
[Using normalized payee matching for accuracy]
Found 34 subscriptions with charges in the past year. Starting interview...
─────────────────────────────────────────────
📺 STREAMING & ENTERTAINMENT
Active Monthly: $85.93 | Annual: $1,031.17
| Service | Monthly | Last Charged | Status |
|--------------|---------|--------------|-----------|
| HBO Max | $15.82 | 2025-12-27 | ✅ Active |
| Paramount+ | $8.66 | 2025-12-24 | ✅ Active |
| Crunchyroll | $5.33 | 2025-12-26 | ✅ Active |
| Hulu | $3.16 | 2025-12-25 | ✅ Active |
| Audible | $11.21 | 2026-01-04 | ✅ Active |
| Spotify | $9.99 | Never (12mo) | ❌ Cancelled |
| Disney+ | $7.99 | Never (12mo) | ❌ Cancelled |
Which do you want to keep?
[ ] Keep 2-3 favorites
[ ] Rotate quarterly
[x] Cancel all
User: Cancel all
─────────────────────────────────────────────
─────────────────────────────────────────────
💻 DEVELOPER TOOLS & HOSTING
Active Monthly: $48.54 | Annual: $582.43
| Service | Monthly | Last Charged | Status |
|---------------------|---------|--------------|-------------|
| GitHub | $15.33 | 2025-09-05 | 🔴 136d ago |
| Google Cloud Storage| $14.61 | 2026-01-07 | ✅ Active |
| Ngrok | $10.50 | 2026-01-19 | ✅ Active |
| DigitalOcean | $8.10 | 2025-12-31 | ✅ Active |
User: Cancel DO and Ngrok
─────────────────────────────────────────────
[Continues through all categories...]
## Summary of Decisions
| Action | Services | Monthly Savings |
|--------|----------|-----------------|
| Cancel | Streaming (5 active) | $44 |
| Cancel | Dev tools (2 services) | $19 |
| Cancel | Productivity (4 services) | $80 |
| Downgrade | Claude.ai ($218→$100) | $118 |
| **Total** | | **$261/mo ($3,132/yr)** |
Action items saved to: data/MEMORY.md
Report saved to: data/reports/2026-01-19_subscription-audit-12mo.md
Next steps:
- [ ] Cancel streaming services (HBO, Paramount+, Crunchyroll, Hulu, Audible)
- [ ] Cancel DigitalOcean and Ngrok
- [ ] Downgrade Claude.ai to $100 tier
- [ ] Review Patreon creators by 2026-02-01
Notes
- All amounts are in the user's primary currency unless specified
- Debit transactions should be treated as negative (expenses)
- Credit transactions are positive (income/refunds)
- Don't trust
recurring_items.transactions_within_range- always cross-reference with actual transactions - Default analysis window is 12 months to catch annual subscriptions and show accurate "Last Charged" dates
- The API returns a maximum of 1000 transactions per request; use pagination for larger date ranges
- Cache is stored in
data/which should be gitignored - Reports are timestamped for historical comparison
Status Legend (in reports)
| Status | Meaning |
|---|---|
| ✅ Active | Last charged within 45 days |
| ⚠️ Stale | Last charge 45-90 days ago (may be cancelled or annual) |
| 🔴 Xd ago | Shows days since last charge (e.g., "🔴 136d ago" for GitHub) |
| ❌ Cancelled | No charges found in 12 months |
Payee Matching Notes
- Names are normalized: "STARLINK INTERNET LLC" → "starlink internet"
- Matching is price-agnostic (same vendor, different amounts still match)
- Keyword extraction catches brand name variations
- Apple charges are mixed (TV, iCloud, App Store) - hard to separate by merchant name alone