Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 35 additions & 28 deletions src/tally/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def analyze_transactions(transactions):
"""Analyze transactions and return summary statistics."""
by_category = defaultdict(lambda: {'count': 0, 'total': 0})
by_merchant = defaultdict(lambda: {
'name': '',
'count': 0,
'total': 0,
'category': '',
Expand Down Expand Up @@ -89,14 +90,17 @@ def analyze_transactions(transactions):

month_key = txn['date'].strftime('%Y-%m')

# Track by merchant
by_merchant[txn['merchant']]['count'] += 1
by_merchant[txn['merchant']]['total'] += effective_amount
by_merchant[txn['merchant']]['category'] = txn['category']
by_merchant[txn['merchant']]['subcategory'] = txn['subcategory']
by_merchant[txn['merchant']]['months'].add(month_key)
by_merchant[txn['merchant']]['monthly_amounts'][month_key] += effective_amount
by_merchant[txn['merchant']]['payments'].append(effective_amount)
# Track by merchant - use composite key (merchant, category, subcategory)
# so same-named merchants with different categories appear as separate rows
merchant_key = (txn['merchant'], txn['category'], txn['subcategory'])
by_merchant[merchant_key]['name'] = txn['merchant']
by_merchant[merchant_key]['count'] += 1
by_merchant[merchant_key]['total'] += effective_amount
by_merchant[merchant_key]['category'] = txn['category']
by_merchant[merchant_key]['subcategory'] = txn['subcategory']
by_merchant[merchant_key]['months'].add(month_key)
by_merchant[merchant_key]['monthly_amounts'][month_key] += effective_amount
by_merchant[merchant_key]['payments'].append(effective_amount)
txn_data = {
'date': txn['date'].strftime('%m/%d'),
'month': month_key,
Expand All @@ -112,18 +116,18 @@ def analyze_transactions(transactions):
# Include original_description if transform was applied
if txn.get('original_description'):
txn_data['original_description'] = txn['original_description']
by_merchant[txn['merchant']]['transactions'].append(txn_data)
by_merchant[merchant_key]['transactions'].append(txn_data)
# Track max payment
if effective_amount > by_merchant[txn['merchant']]['max_payment']:
by_merchant[txn['merchant']]['max_payment'] = effective_amount
if effective_amount > by_merchant[merchant_key]['max_payment']:
by_merchant[merchant_key]['max_payment'] = effective_amount
# Store match info (pattern that matched) - first transaction sets this
if 'match_info' not in by_merchant[txn['merchant']] and txn.get('match_info'):
by_merchant[txn['merchant']]['match_info'] = txn['match_info']
if 'match_info' not in by_merchant[merchant_key] and txn.get('match_info'):
by_merchant[merchant_key]['match_info'] = txn['match_info']
# Collect tags from all transactions
by_merchant[txn['merchant']]['tags'].update(txn.get('tags', []))
by_merchant[merchant_key]['tags'].update(txn.get('tags', []))
# Track raw description variations
raw_desc = txn.get('raw_description', txn.get('description', ''))
by_merchant[txn['merchant']]['raw_descriptions'][raw_desc] += 1
by_merchant[merchant_key]['raw_descriptions'][raw_desc] += 1

by_month[month_key] += effective_amount

Expand Down Expand Up @@ -224,7 +228,8 @@ def classify_by_sections(by_merchant, sections_config, num_months=12):

# Convert by_merchant to the format expected by section_engine
merchant_groups = []
for merchant_name, data in by_merchant.items():
for merchant_key, data in by_merchant.items():
merchant_name = data.get('name', '')
# Skip merchants excluded from spending (income, transfer, investment)
# They appear on their respective cards, not in spending sections
if is_excluded_from_spending(list(data.get('tags', []))):
Expand Down Expand Up @@ -435,21 +440,22 @@ def export_json(stats, verbose=0, category_filter=None, merchant_filter=None):
if data['total'] > 0
],
'credits': [
{'merchant': name, 'category': data.get('category', ''), 'amount': round(abs(data['total']), 2)}
for name, data in by_merchant.items() if data['total'] < 0
{'merchant': data.get('name', ''), 'category': data.get('category', ''), 'amount': round(abs(data['total']), 2)}
for data in by_merchant.values() if data['total'] < 0
],
'merchants': []
}

merchants = []
for name, data in by_merchant.items():
for data in by_merchant.values():
merchant_name = data.get('name', '')
# Apply filters
if category_filter and data.get('category') != category_filter:
continue
if merchant_filter and name not in merchant_filter:
if merchant_filter and merchant_name not in merchant_filter:
continue

merchants.append(build_merchant_json(name, data, verbose))
merchants.append(build_merchant_json(merchant_name, data, verbose))

# Sort by monthly value descending
merchants.sort(key=lambda x: x['monthly_value'], reverse=True)
Expand Down Expand Up @@ -526,7 +532,7 @@ def fmt(amount, show_sign=False):
lines.append('')

# Credits/Refunds
credit_merchants = [(m, d) for m, d in by_merchant.items() if d['total'] < 0]
credit_merchants = [(d.get('name', ''), d) for d in by_merchant.values() if d['total'] < 0]
if credit_merchants:
lines.append('## Credits/Refunds\n')
lines.append('| Merchant | Category | Amount |')
Expand All @@ -550,7 +556,7 @@ def fmt(amount, show_sign=False):
lines.append("## Merchants\n")

# Sort by monthly value (positive merchants only)
positive_merchants = [(m, d) for m, d in by_merchant.items() if d['total'] > 0]
positive_merchants = [(d.get('name', ''), d) for d in by_merchant.values() if d['total'] > 0]
sorted_merchants = sorted(
positive_merchants,
key=lambda x: x[1].get('monthly_value', 0),
Expand Down Expand Up @@ -610,7 +616,8 @@ def export_csv(stats, category_filter=None, merchant_filter=None):
all_transactions = []
extra_field_names = set()

for merchant_name, data in by_merchant.items():
for _merchant_key, data in by_merchant.items():
merchant_name = data.get('name', '')
# Apply filters
if category_filter and data.get('category') != category_filter:
continue
Expand Down Expand Up @@ -727,7 +734,7 @@ def fmt(amount):
# =========================================================================
# CREDITS/REFUNDS (if any negative totals)
# =========================================================================
credit_merchants = [(m, d) for m, d in by_merchant.items() if d['total'] < 0]
credit_merchants = [(d.get('name', ''), d) for d in by_merchant.values() if d['total'] < 0]
if credit_merchants:
print("\n" + "=" * 80)
print("CREDITS/REFUNDS")
Expand Down Expand Up @@ -766,7 +773,7 @@ def fmt(amount):
print("-" * 80)

# Only show positive-total merchants here (credits shown separately)
positive_merchants = [(m, d) for m, d in by_merchant.items() if d['total'] > 0]
positive_merchants = [(d.get('name', ''), d) for d in by_merchant.values() if d['total'] > 0]
sorted_merchants = sorted(
positive_merchants,
key=lambda x: x[1].get('total', 0),
Expand Down Expand Up @@ -811,13 +818,13 @@ def fmt(amount):

# Build category -> merchants mapping
cat_merchants = {}
for merchant, data in by_merchant.items():
for _merchant_key, data in by_merchant.items():
if data['total'] <= 0:
continue
cat = data.get('category', 'Unknown')
if cat not in cat_merchants:
cat_merchants[cat] = []
cat_merchants[cat].append((merchant, data))
cat_merchants[cat].append((data.get('name', ''), data))

# Sort categories by total
sorted_cats = sorted(
Expand Down
42 changes: 31 additions & 11 deletions src/tally/commands/explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,28 +115,40 @@ def cmd_explain(args):
# Handle output based on what was requested
if merchant_names:
# Explain specific merchants
# Build a lookup from merchant name -> list of data entries
# (same merchant name may appear with different categories as separate entries)
name_to_entries = {}
for merchant_data in all_merchants.values():
merchant_name = merchant_data.get('name', '')
if merchant_name not in name_to_entries:
name_to_entries[merchant_name] = []
name_to_entries[merchant_name].append(merchant_data)

found_any = False
for merchant_query in merchant_names:
# Try exact match first
if merchant_query in all_merchants:
if merchant_query in name_to_entries:
found_any = True
_print_merchant_explanation(merchant_query, all_merchants[merchant_query], args.format, verbose, stats['num_months'], views_config)
for entry in name_to_entries[merchant_query]:
_print_merchant_explanation(merchant_query, entry, args.format, verbose, stats['num_months'], views_config)
else:
# Try case-insensitive match
matches = [m for m in all_merchants.keys() if m.lower() == merchant_query.lower()]
matches = [n for n in name_to_entries.keys() if n.lower() == merchant_query.lower()]
if matches:
found_any = True
_print_merchant_explanation(matches[0], all_merchants[matches[0]], args.format, verbose, stats['num_months'], views_config)
for entry in name_to_entries[matches[0]]:
_print_merchant_explanation(matches[0], entry, args.format, verbose, stats['num_months'], views_config)
continue

# Try substring match on merchant names (partial search)
query_lower = merchant_query.lower()
partial_matches = [m for m in all_merchants.keys() if query_lower in m.lower()]
partial_matches = [n for n in name_to_entries.keys() if query_lower in n.lower()]
if partial_matches:
found_any = True
print(f"Merchants matching '{merchant_query}':\n")
for m in sorted(partial_matches):
_print_merchant_explanation(m, all_merchants[m], args.format, verbose, stats['num_months'], views_config)
for n in sorted(partial_matches):
for entry in name_to_entries[n]:
_print_merchant_explanation(n, entry, args.format, verbose, stats['num_months'], views_config)
continue

# Search transactions containing the query
Expand Down Expand Up @@ -182,7 +194,7 @@ def cmd_explain(args):
_print_description_explanation(merchant_query, trace, args.format, verbose)
else:
# Try fuzzy match on merchant names
close_matches = get_close_matches(merchant_query, list(all_merchants.keys()), n=3, cutoff=0.6)
close_matches = get_close_matches(merchant_query, list(name_to_entries.keys()), n=3, cutoff=0.6)
if close_matches:
print(f"No merchant matching '{merchant_query}'. Did you mean:", file=sys.stderr)
for m in close_matches:
Expand Down Expand Up @@ -236,7 +248,14 @@ def cmd_explain(args):
sys.exit(1)

merchants_list = view_results[view_match]
matching_merchants = {name: data for name, data in merchants_list}
# Use composite key to avoid collisions when same-named merchants
# appear with different categories in the same view
matching_merchants = {}
for merch_name, data in merchants_list:
cat = data.get('category', '')
subcat = data.get('subcategory', '')
key = (merch_name, cat, subcat)
matching_merchants[key] = data
active_filters.append(f"view:{view_match}")

# Apply category filter
Expand Down Expand Up @@ -289,7 +308,7 @@ def cmd_explain(args):

if args.format == 'json':
import json
merchants = [build_merchant_json(name, data, verbose) for name, data in matching_merchants.items()]
merchants = [build_merchant_json(data.get('name', ''), data, verbose) for data in matching_merchants.values()]
merchants.sort(key=lambda x: x['monthly_value'], reverse=True)
output = {'filters': active_filters, 'merchants': merchants}
print(json.dumps(output, indent=2))
Expand Down Expand Up @@ -805,7 +824,8 @@ def _print_classification_summary(section, merchants_dict, verbose, num_months):
print("-" * 50)

sorted_merchants = sorted(merchants_dict.items(), key=lambda x: x[1].get('monthly_value', 0), reverse=True)
for name, data in sorted_merchants:
for key, data in sorted_merchants:
name = data.get('name', str(key))
reasoning = data.get('reasoning', {})
category = data.get('category', '')
months = data.get('months_active', 0)
Expand Down
39 changes: 28 additions & 11 deletions src/tally/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,13 @@ def make_merchant_id(name):
# Build section merchants data
def build_section_merchants(merchant_dict):
merchants = {}
for merchant_name, data in merchant_dict.items():
merchant_id = make_merchant_id(merchant_name)
for merchant_key, data in merchant_dict.items():
# Display name comes from data['name'] when available; fall back to the key
merchant_name = data.get('name', merchant_key if isinstance(merchant_key, str) else str(merchant_key))
category = data.get('category', '')
subcategory = data.get('subcategory', '')
# Use a composite ID so same-named merchants with different categories are distinct
merchant_id = make_merchant_id(f"{merchant_name}_{category}_{subcategory}")

# Build transactions array with unique IDs
txns = []
Expand Down Expand Up @@ -157,18 +162,18 @@ def build_section_merchants(merchant_dict):
'source': match_info.get('source', ''),
'explanation': explain_pattern(pattern),
'assignedMerchant': merchant_name,
'assignedCategory': data.get('category', ''),
'assignedSubcategory': data.get('subcategory', ''),
'assignedCategory': category,
'assignedSubcategory': subcategory,
'assignedTags': sorted(match_info.get('tags', [])),
'tagSources': match_info.get('tag_sources', {}),
}

merchants[merchant_id] = {
'id': merchant_id,
'displayName': merchant_name,
'category': data.get('category', 'Other'),
'subcategory': data.get('subcategory', 'Uncategorized'),
'categoryPath': f"{data.get('category', 'Other')}/{data.get('subcategory', 'Uncategorized')}".lower(),
'category': category or 'Other',
'subcategory': subcategory or 'Uncategorized',
'categoryPath': f"{category or 'Other'}/{subcategory or 'Uncategorized'}".lower(),
'calcType': data.get('calc_type', '/12'),
'monthsActive': data.get('months_active', 0),
'isConsistent': data.get('is_consistent', False),
Expand Down Expand Up @@ -204,7 +209,14 @@ def build_section_merchants(merchant_dict):
continue

# Convert list of (name, data) tuples to dict format
merchant_dict = {name: data for name, data in merchants_list}
# Use a composite key to avoid collisions when the same merchant name
# appears with different categories in the same section
merchant_dict = {}
for merch_name, data in merchants_list:
cat = data.get('category', '')
subcat = data.get('subcategory', '')
unique_key = f"{merch_name}||{cat}||{subcat}" if (cat or subcat) else merch_name
merchant_dict[unique_key] = data
merchants = build_section_merchants(merchant_dict)

# Add view info to each merchant
Expand Down Expand Up @@ -239,9 +251,14 @@ def build_category_view():
# Build from by_merchant which contains ALL merchants (not filtered by sections)
all_merchants = {}
by_merchant = stats.get('by_merchant', {})
for merchant_name, data in by_merchant.items():
merchant_id = make_merchant_id(merchant_name)
all_merchants[merchant_id] = build_section_merchants({merchant_name: data})[merchant_id]
for merchant_key, data in by_merchant.items():
merchant_name = data.get('name', '')
category = data.get('category', '')
subcategory = data.get('subcategory', '')
# Use composite key to ensure uniqueness for same-name merchants in different categories
unique_key = f"{merchant_name}||{category}||{subcategory}" if (category or subcategory) else merchant_name
sub_result = build_section_merchants({unique_key: data})
all_merchants.update(sub_result)

# Group by category -> subcategory
categories = {}
Expand Down
Loading