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
17 changes: 14 additions & 3 deletions moneyflow/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
import polars as pl

from .backends.base import FinanceBackend
from .categories import build_category_to_group_mapping, get_effective_category_groups
from .categories import (
build_category_to_group_mapping,
convert_api_categories_to_groups,
get_effective_category_groups,
save_categories_to_config,
)
from .logging_config import get_logger

logger = get_logger(__name__)
Expand Down Expand Up @@ -273,11 +278,17 @@ async def fetch_all_data(
getattr(self.mm, "__class__", None).__name__ if hasattr(self.mm, "__class__") else None
)
if backend_type and backend_type not in ["DemoBackend", "AmazonBackend"]:
from .categories import convert_api_categories_to_groups, save_categories_to_config

try:
simple_groups = convert_api_categories_to_groups(categories_data, groups_data)
save_categories_to_config(simple_groups, config_dir=self.config_dir)

# Rebuild category mapping after saving fresh categories
# This fixes bug where stale mapping causes transfers to not be filtered
self.category_groups_config = get_effective_category_groups(self.config_dir)
self.category_to_group = build_category_to_group_mapping(
self.category_groups_config
)
logger.debug("Rebuilt category-to-group mapping with fresh categories")
except Exception as e:
logger.warning(f"Failed to save categories to config.yaml: {e}")

Expand Down
4 changes: 2 additions & 2 deletions moneyflow/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ def format_transaction_rows(
df: pl.DataFrame,
selected_ids: set[str],
pending_edit_ids: set[str],
) -> list[tuple[str, str, str, str, str, str]]:
) -> list[tuple[str, str, str, str, Union[str, Text], str]]:
"""
Format transaction DataFrame rows for display.

Expand All @@ -517,7 +517,7 @@ def format_transaction_rows(
>>> rows[0][0] # date
'2025-01-15'
"""
rows: list[tuple[str, str, str, str, str, str]] = []
rows: list[tuple[str, str, str, str, Union[str, Text], str]] = []

for row_dict in df.iter_rows(named=True):
date = str(row_dict["date"])
Expand Down
118 changes: 118 additions & 0 deletions tests/test_data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,3 +797,121 @@ def progress_callback(msg: str):
assert len(transactions) > 0
# Should have progress messages without percentage
assert any("Downloaded" in msg and "%" not in msg for msg in progress_messages)


class TestCategoryMappingRefresh:
"""
Regression test for category mapping refresh bug.

Bug: category_to_group mapping was built once from stale config.yaml
and never updated after fetching fresh categories from API, causing
transfers to not be filtered correctly.
"""

async def test_category_mapping_refreshes_after_fetch(self, mock_mm, tmp_path):
"""
Test that category_to_group mapping is rebuilt after fetching fresh categories.

Bug scenario:
1. config.yaml has stale/incomplete categories (missing Transfers)
2. DataManager.__init__() loads stale categories, builds mapping
3. fetch_all_data() fetches fresh categories (including Transfers)
4. category_to_group mapping should be updated to include Transfers

Without the fix, transfers wouldn't be filtered correctly.
"""
import yaml

config_dir = tmp_path / "config"
config_dir.mkdir()
config_path = config_dir / "config.yaml"

# Create stale config.yaml with ONLY 2 groups (missing Transfers)
stale_config = {
"version": 1,
"fetched_categories": {
"Food & Dining": ["Groceries", "Restaurants"],
"Shopping": ["Clothing"],
},
}

with open(config_path, "w") as f:
yaml.dump(stale_config, f)

# Login to mock backend
await mock_mm.login()

# Initialize DataManager with stale config
dm = DataManager(mock_mm, config_dir=str(config_dir))

# Verify initial mapping is stale (only has 2 groups, no Transfers)
initial_mapping = dm.category_to_group
assert "Transfers" not in set(initial_mapping.values())

# Fetch all data (should fetch fresh categories from API and save to config.yaml)
df, categories, category_groups = await dm.fetch_all_data()

# Verify fresh categories were saved to config.yaml
with open(config_path, "r") as f:
saved_config = yaml.safe_load(f)

assert "fetched_categories" in saved_config
# Mock backend returns 3 groups (Food & Dining, Auto & Transport, Shopping)
assert len(saved_config["fetched_categories"]) == 3

# Verify category_to_group mapping was rebuilt with fresh data
updated_mapping = dm.category_to_group

# Verify categories from mock backend are now in the mapping
assert updated_mapping.get("Groceries") == "Food & Dining"
assert updated_mapping.get("Gas") == "Auto & Transport"

# Verify mapping has all 3 groups from mock backend
assert len(set(updated_mapping.values())) == 3

async def test_categories_get_correct_group_in_dataframe(self, mock_mm, tmp_path):
"""
Test that transactions get correct group after mapping refresh.

User-facing symptom: categories should be correctly mapped to groups
so filtering works properly.
"""
import yaml

config_dir = tmp_path / "config"
config_dir.mkdir()
config_path = config_dir / "config.yaml"

# Create stale config with wrong mappings
stale_config = {
"version": 1,
"fetched_categories": {
"Wrong Group": ["Groceries", "Gas"], # Both in wrong group
},
}

with open(config_path, "w") as f:
yaml.dump(stale_config, f)

# Login to mock backend
await mock_mm.login()

# Initialize DataManager with stale config
dm = DataManager(mock_mm, config_dir=str(config_dir))

# Fetch all data (refreshes categories and rebuilds mapping)
df, _, _ = await dm.fetch_all_data()

# Verify Groceries transactions have correct group
groceries_txns = df.filter(df["category"] == "Groceries")
if len(groceries_txns) > 0:
groups = groceries_txns["group"].unique().to_list()
assert "Food & Dining" in groups
assert "Wrong Group" not in groups

# Verify Gas transactions have correct group
gas_txns = df.filter(df["category"] == "Gas")
if len(gas_txns) > 0:
groups = gas_txns["group"].unique().to_list()
assert "Auto & Transport" in groups
assert "Wrong Group" not in groups
10 changes: 8 additions & 2 deletions tests/test_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,10 @@ def test_formats_large_amount(self):

rows = ViewPresenter.format_transaction_rows(df, set(), set())

assert rows[0][4].plain == "-$12,345.67"
amount_field = rows[0][4]
# Amount field is Text object when for_table=True
assert hasattr(amount_field, "plain")
assert amount_field.plain == "-$12,345.67" # type: ignore[union-attr]

def test_formats_positive_amount(self):
"""Should format positive amounts (income)."""
Expand All @@ -727,7 +730,10 @@ def test_formats_positive_amount(self):

rows = ViewPresenter.format_transaction_rows(df, set(), set())

assert rows[0][4].plain == "+$5,000.00"
amount_field = rows[0][4]
# Amount field is Text object when for_table=True
assert hasattr(amount_field, "plain")
assert amount_field.plain == "+$5,000.00" # type: ignore[union-attr]


class TestPrepareTransactionView:
Expand Down