Skip to content

Commit 8e9fc6a

Browse files
wesmclaude
andauthored
Ensure that Amazon mode uses config.yaml categories, does not overwrite. Remove unneeded "legacy" categories.yaml handling (#22)
I ran into some minor issues when loading up Amazon mode that arose from recent changes. This normalizes behavior and makes sure that if you have synced Monarch or YNAB categories to config.yaml, that these will be used for categorization in Amazon mode, rather than the default/built-in categories which may be more limited. --------- Co-authored-by: Claude <[email protected]>
1 parent fa94bb5 commit 8e9fc6a

File tree

9 files changed

+133
-177
lines changed

9 files changed

+133
-177
lines changed

docs/categories.md

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -86,32 +86,43 @@ This shows the actual categories being used (fetched from backend or defaults).
8686

8787
---
8888

89-
## Advanced: Manual Category Customization (Legacy)
89+
## Advanced: Custom Category Overrides
9090

91-
!!! warning "Not recommended for Monarch/YNAB users"
92-
For Monarch and YNAB users, we recommend using your backend's categories directly.
93-
They're automatically fetched and synced on every startup.
91+
!!! info "Only for advanced users"
92+
Most users don't need this. Monarch/YNAB categories are automatically fetched and synced.
9493

95-
Manual customization is primarily useful for Amazon mode users who want to define their
96-
own category structure.
97-
98-
If you need to manually define categories (e.g., for Amazon-only usage without Monarch/YNAB),
99-
you can create a custom category structure in `config.yaml`:
94+
You can customize how categories are organized by editing `~/.moneyflow/config.yaml`:
10095

10196
```yaml
10297
version: 1
10398
104-
# Manually defined categories (overrides fetched_categories if present)
105-
custom_categories:
106-
Food:
99+
# Backend categories (auto-populated by Monarch/YNAB)
100+
fetched_categories:
101+
Food & Dining:
107102
- Groceries
108103
- Restaurants
109-
Shopping:
110-
- Clothing
111-
- Electronics
104+
105+
# Optional: Custom overrides (applied on top of fetched_categories)
106+
categories:
107+
rename_groups:
108+
"Food & Dining": "Food"
109+
add_to_groups:
110+
Food:
111+
- Fast Food
112112
```
113113

114-
**Note:** Manual customization is rarely needed with the new auto-fetch system.
114+
**Available customizations:**
115+
116+
- `rename_groups` - Rename category groups
117+
- `rename_categories` - Rename individual categories
118+
- `add_to_groups` - Add categories to existing groups
119+
- `custom_groups` - Create entirely new groups
120+
- `move_categories` - Move categories between groups
121+
122+
**Which backends write to config.yaml:**
123+
124+
- Monarch/YNAB: Write `fetched_categories` on every startup
125+
- Amazon/Demo: Only read, never write
115126

116127
---
117128

@@ -166,7 +177,12 @@ fetched_categories:
166177
- Category 2
167178
```
168179

169-
**Fallback order:**
180+
**Category resolution (two-step process):**
181+
182+
1. **Base categories** (one or the other, NOT merged):
183+
- `fetched_categories` from config.yaml (if present)
184+
- OR built-in `DEFAULT_CATEGORY_GROUPS` from `categories.py`
170185

171-
1. `fetched_categories` from config.yaml
172-
2. Built-in `DEFAULT_CATEGORY_GROUPS` from `categories.py`
186+
2. **Custom overrides** (merged on top of base):
187+
- `categories` section from config.yaml (if present)
188+
- Applied via rename_groups, add_to_groups, etc.

docs/config/advanced.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,3 @@ All moneyflow configuration is stored in `~/.moneyflow/`:
5454
```
5555

5656
**Security note:** credentials.enc is encrypted with AES-128. Safe to backup but keep private.
57-
58-
**Backward compatibility:** Legacy `categories.yaml` files are still supported.

moneyflow/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1915,12 +1915,13 @@ def launch_monarch_mode(
19151915
sys.exit(1)
19161916

19171917

1918-
def launch_amazon_mode(db_path: Optional[str] = None) -> None:
1918+
def launch_amazon_mode(db_path: Optional[str] = None, config_dir: Optional[str] = None) -> None:
19191919
"""
19201920
Launch moneyflow in Amazon purchase analysis mode.
19211921
19221922
Args:
19231923
db_path: Path to Amazon SQLite database (default: ~/.moneyflow/amazon.db)
1924+
config_dir: Config directory for loading categories (default: ~/.moneyflow)
19241925
19251926
Uses the AmazonBackend with data stored in SQLite.
19261927
Data must be imported first using: moneyflow amazon import <csv>
@@ -1929,12 +1930,14 @@ def launch_amazon_mode(db_path: Optional[str] = None) -> None:
19291930
from moneyflow.backends.amazon import AmazonBackend
19301931

19311932
# Initialize logging
1932-
logger = setup_logging(console_output=False, config_dir=None)
1933+
logger = setup_logging(console_output=False, config_dir=config_dir)
19331934
logger.info("Starting moneyflow in Amazon mode")
1935+
if config_dir:
1936+
logger.info(f"Using custom config directory: {config_dir}")
19341937

19351938
try:
19361939
# Create Amazon backend and config
1937-
backend = AmazonBackend(db_path=db_path)
1940+
backend = AmazonBackend(db_path=db_path, config_dir=config_dir)
19381941
config = BackendConfig.for_amazon()
19391942

19401943
# Create MoneyflowApp in Amazon mode

moneyflow/backends/amazon.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pathlib import Path
1111
from typing import Any, Dict, List, Optional
1212

13-
from ..categories import DEFAULT_CATEGORY_GROUPS
13+
from ..categories import get_effective_category_groups
1414
from .base import FinanceBackend
1515

1616

@@ -25,19 +25,21 @@ class AmazonBackend(FinanceBackend):
2525
imported from CSV files exported from Amazon.com.
2626
"""
2727

28-
def __init__(self, db_path: Optional[str] = None):
28+
def __init__(self, db_path: Optional[str] = None, config_dir: Optional[str] = None):
2929
"""
3030
Initialize the Amazon backend.
3131
3232
Args:
3333
db_path: Path to SQLite database. Defaults to ~/.moneyflow/amazon.db
34+
config_dir: Config directory for loading categories. Defaults to ~/.moneyflow
3435
3536
Note: Database file is not created until first access (lazy initialization).
3637
"""
3738
if db_path is None:
3839
db_path = str(Path.home() / ".moneyflow" / "amazon.db")
3940

4041
self.db_path = Path(db_path).expanduser()
42+
self.config_dir = config_dir
4143
self._db_initialized = False
4244

4345
def _ensure_db_initialized(self) -> None:
@@ -282,19 +284,24 @@ async def get_transactions(
282284

283285
async def get_transaction_categories(self) -> Dict[str, Any]:
284286
"""
285-
Fetch all categories from centralized category list.
287+
Fetch all categories from config.yaml (or defaults if not present).
286288
287-
Returns categories from categories.py (not database) to avoid data duplication
288-
and allow easy updates via config file.
289+
Returns categories from config.yaml if available (e.g., fetched from Monarch),
290+
otherwise uses built-in defaults. This allows Amazon mode to use the same
291+
category structure as your primary backend.
289292
290293
Returns:
291294
Dictionary containing categories in standard format
292295
"""
293296
categories = []
294297
cat_id_counter = 1
295298

296-
# Build categories from DEFAULT_CATEGORY_GROUPS
297-
for group_name, category_names in DEFAULT_CATEGORY_GROUPS.items():
299+
# Load category groups from config.yaml if available, otherwise use built-in defaults
300+
# Note: This is NOT a merge - it's one or the other (priority: config.yaml > defaults)
301+
category_groups = get_effective_category_groups(self.config_dir)
302+
303+
# Build categories from loaded category groups
304+
for group_name, category_names in category_groups.items():
298305
for cat_name in category_names:
299306
cat_id = f"cat_{cat_name.lower().replace(' ', '_').replace('&', 'and')}"
300307
categories.append(
@@ -348,11 +355,12 @@ async def update_transaction(
348355
if category_id is not None:
349356
updates.append("category_id = ?")
350357
params.append(category_id)
351-
# Also update category name from DEFAULT_CATEGORY_GROUPS
358+
# Also update category name from effective category groups
352359
# (group is derived from category by data_manager, not stored)
353360
# Build category_id → category_name lookup
361+
category_groups = get_effective_category_groups(self.config_dir)
354362
category_name = None
355-
for group_name, category_names in DEFAULT_CATEGORY_GROUPS.items():
363+
for group_name, category_names in category_groups.items():
356364
for cat_name in category_names:
357365
cat_id = f"cat_{cat_name.lower().replace(' ', '_').replace('&', 'and')}"
358366
if cat_id == category_id:

moneyflow/categories.py

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,6 @@ def load_custom_categories(config_dir: Optional[str] = None) -> Optional[Dict[st
137137
"""
138138
Load custom category configuration from ~/.moneyflow/config.yaml.
139139
140-
Supports both new config.yaml format (with categories: section) and
141-
legacy categories.yaml format (for backward compatibility).
142-
143140
Args:
144141
config_dir: Optional custom config directory (default: ~/.moneyflow)
145142
@@ -166,9 +163,7 @@ def load_custom_categories(config_dir: Optional[str] = None) -> Optional[Dict[st
166163
config_dir = str(Path.home() / ".moneyflow")
167164

168165
config_path = Path(config_dir) / "config.yaml"
169-
legacy_path = Path(config_dir) / "categories.yaml"
170166

171-
# Try new config.yaml format first
172167
if config_path.exists():
173168
try:
174169
with open(config_path, "r") as f:
@@ -199,39 +194,8 @@ def load_custom_categories(config_dir: Optional[str] = None) -> Optional[Dict[st
199194
except Exception as e:
200195
logger.error(f"Failed to load config: {e}")
201196
return None
202-
203-
# Fall back to legacy categories.yaml
204-
elif legacy_path.exists():
205-
logger.warning(
206-
"Using legacy categories.yaml. Please rename to config.yaml and nest under 'categories:' section"
207-
)
208-
try:
209-
with open(legacy_path, "r") as f:
210-
config = yaml.safe_load(f)
211-
212-
if not config:
213-
logger.warning(f"Empty categories config at {legacy_path}")
214-
return None
215-
216-
# Validate version
217-
version = config.get("version")
218-
if version != 1:
219-
logger.warning(f"Unsupported categories.yaml version: {version} (expected 1)")
220-
return None
221-
222-
logger.info(f"Loaded custom categories from {legacy_path} (legacy format)")
223-
# Return config directly (legacy format has categories at top level)
224-
return config
225-
226-
except yaml.YAMLError as e:
227-
logger.error(f"Failed to parse {legacy_path}: {e}")
228-
return None
229-
except Exception as e:
230-
logger.error(f"Failed to load legacy categories: {e}")
231-
return None
232-
233197
else:
234-
logger.debug(f"No config file at {config_path} or {legacy_path}")
198+
logger.debug(f"No config file at {config_path}")
235199
return None
236200

237201

@@ -417,10 +381,10 @@ def save_categories_to_config(
417381
# Ensure version is set
418382
config["version"] = 1
419383

420-
# Store fetched categories
384+
# Store fetched categories (preserves all other existing keys in config)
421385
config["fetched_categories"] = category_groups
422386

423-
# Write back to file
387+
# Write back to file (preserving all existing keys like 'categories', etc.)
424388
Path(config_dir).mkdir(parents=True, exist_ok=True, mode=0o700)
425389
with open(config_path, "w") as f:
426390
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
@@ -450,18 +414,20 @@ def build_category_to_group_mapping(category_groups: Dict[str, List[str]]) -> Di
450414

451415
def get_effective_category_groups(config_dir: Optional[str] = None) -> Dict[str, List[str]]:
452416
"""
453-
Get effective category groups with priority order:
417+
Get category groups (NOT a merge - returns one or the other).
418+
419+
Priority order:
454420
1. Fetched categories from backend API (stored in config.yaml)
455421
2. Built-in defaults from categories.py
456422
457-
For Monarch/YNAB: Uses fetched_categories from config.yaml (populated on every startup)
423+
For Monarch/YNAB: Uses fetched_categories from config.yaml (populated on startup)
458424
For Demo/Amazon: Uses fetched_categories if available, otherwise built-in defaults
459425
460426
Args:
461427
config_dir: Optional custom config directory (default: ~/.moneyflow)
462428
463429
Returns:
464-
Final category groups dict
430+
Category groups dict (either from config.yaml OR defaults, never merged)
465431
"""
466432
if config_dir is None:
467433
config_dir = str(Path.home() / ".moneyflow")

moneyflow/cli.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -81,23 +81,30 @@ def cli(ctx, year, since, mtd, cache, refresh, demo, config_dir):
8181
default=None,
8282
help="Path to Amazon SQLite database (default: ~/.moneyflow/amazon.db)",
8383
)
84+
@click.option(
85+
"--config-dir",
86+
type=click.Path(),
87+
default=None,
88+
help="Config directory (default: ~/.moneyflow). Used for loading categories from config.yaml.",
89+
)
8490
@click.pass_context
85-
def amazon(ctx, db_path):
91+
def amazon(ctx, db_path, config_dir):
8692
"""Amazon purchase analysis mode.
8793
8894
Run 'moneyflow amazon' to launch the UI.
8995
Use subcommands for import/status operations.
9096
"""
91-
# Store db_path in context for subcommands
97+
# Store db_path and config_dir in context for subcommands
9298
ctx.ensure_object(dict)
9399
ctx.obj["db_path"] = db_path
100+
ctx.obj["config_dir"] = config_dir
94101

95102
# If no subcommand, launch the UI
96103
if ctx.invoked_subcommand is None:
97104
from moneyflow.app import launch_amazon_mode
98105
from moneyflow.backends.amazon import AmazonBackend
99106

100-
backend = AmazonBackend(db_path=db_path)
107+
backend = AmazonBackend(db_path=db_path, config_dir=config_dir)
101108

102109
# Check if database exists
103110
if not backend.db_path.exists():
@@ -117,7 +124,7 @@ def amazon(ctx, db_path):
117124
raise click.Abort()
118125

119126
# Launch the UI
120-
launch_amazon_mode(db_path=db_path)
127+
launch_amazon_mode(db_path=db_path, config_dir=config_dir)
121128

122129

123130
@amazon.command(name="import")
@@ -245,12 +252,11 @@ def categories():
245252
help="Output format: yaml (copy-pastable) or readable (with counts)",
246253
)
247254
def categories_dump(config_dir, format):
248-
"""Display current category hierarchy (defaults + custom from config.yaml).
255+
"""Display current category hierarchy.
249256
250-
Shows the effective category structure including:
251-
- Built-in defaults
252-
- Custom categories from ~/.moneyflow/config.yaml
253-
- Category renames and moves
257+
Shows categories from config.yaml if available (fetched from backend),
258+
otherwise shows built-in defaults. This is NOT a merge - it's one or
259+
the other (priority: config.yaml > defaults).
254260
255261
Default output is YAML format (copy-pastable into config.yaml under 'categories:').
256262
Use --format=readable for human-readable format with counts.
@@ -300,16 +306,12 @@ def categories_dump(config_dir, format):
300306
# Show config file location
301307
if config_dir:
302308
config_path = Path(config_dir) / "config.yaml"
303-
legacy_path = Path(config_dir) / "categories.yaml"
304309
else:
305310
config_path = Path.home() / ".moneyflow" / "config.yaml"
306-
legacy_path = Path.home() / ".moneyflow" / "categories.yaml"
307311

308312
click.echo(f"\n# {'=' * 58}")
309313
if config_path.exists():
310314
click.echo(f"# Custom config: {config_path}")
311-
elif legacy_path.exists():
312-
click.echo(f"# Custom config: {legacy_path} (legacy format)")
313315
else:
314316
click.echo(f"# Using built-in defaults (no custom config at {config_path})")
315317

moneyflow/data_manager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def __init__(
7878
Args:
7979
mm: Backend instance (must implement FinanceBackend interface)
8080
merchant_cache_dir: Directory for merchant cache (defaults to ~/.moneyflow/)
81-
config_dir: Optional config directory for categories.yaml (defaults to ~/.moneyflow/)
81+
config_dir: Optional config directory for config.yaml (defaults to ~/.moneyflow/)
8282
"""
8383
self.mm = mm
8484
self.config_dir = config_dir # Store for apply_category_groups
@@ -407,7 +407,7 @@ def _transactions_to_dataframe(
407407
Convert raw transaction data to Polars DataFrame with enriched fields.
408408
409409
Note: Does NOT include 'group' field - groups are applied dynamically
410-
via apply_category_groups() so changes to categories.yaml take effect
410+
via apply_category_groups() so changes to config.yaml take effect
411411
on cached data.
412412
"""
413413
if not transactions:
@@ -458,9 +458,9 @@ def apply_category_groups(self, df: pl.DataFrame) -> pl.DataFrame:
458458
Apply category-to-group mapping to a DataFrame.
459459
460460
This adds/updates the 'group' column based on category groups from
461-
categories module (defaults + custom from ~/.moneyflow/categories.yaml).
461+
config.yaml (or built-in defaults if config.yaml not present).
462462
Called after loading data (from API or cache) so that changes to
463-
categories.yaml always take effect.
463+
config.yaml always take effect.
464464
465465
Args:
466466
df: DataFrame with 'category' column

0 commit comments

Comments
 (0)