diff --git a/src/tally/analyzer.py b/src/tally/analyzer.py index 0f5f1c1..30e00a7 100644 --- a/src/tally/analyzer.py +++ b/src/tally/analyzer.py @@ -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': '', @@ -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, @@ -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 @@ -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', []))): @@ -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) @@ -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 |') @@ -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), @@ -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 @@ -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") @@ -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), @@ -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( diff --git a/src/tally/commands/explain.py b/src/tally/commands/explain.py index 25f9d30..0047009 100644 --- a/src/tally/commands/explain.py +++ b/src/tally/commands/explain.py @@ -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 @@ -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: @@ -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 @@ -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)) @@ -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) diff --git a/src/tally/report.py b/src/tally/report.py index 2435017..045280a 100644 --- a/src/tally/report.py +++ b/src/tally/report.py @@ -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 = [] @@ -157,8 +162,8 @@ 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', {}), } @@ -166,9 +171,9 @@ def build_section_merchants(merchant_dict): 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), @@ -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 @@ -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 = {}