"""Tests for the YNAB import helpers in main.py.""" import csv import tempfile from pathlib import Path from types import SimpleNamespace from category_suggester import CategoryHistory, normalize_payee from main import ( _wf_to_ynab_millis, _build_import_ids, _convert_wf_to_ynab_transactions, _ImportClassification, _apply_category_suggestions, _classify_for_import, _execute_import, _format_amount_dollars, _load_category_history, _preview_import, _write_transactions_csv, ) def test_wf_to_ynab_millis(): # Credit card charge (no debitCreditType) -> outflow (negative) assert _wf_to_ynab_millis({"transactionAmount": 9.11}) == -9110 # Credit card refund (debitCreditType=CREDIT) -> inflow (positive) assert _wf_to_ynab_millis({"transactionAmount": 1.64, "debitCreditType": "CREDIT"}) == 1640 # Debit card charge (debitCreditType=DEBIT) -> outflow (negative) assert _wf_to_ynab_millis({"transactionAmount": 194.32, "debitCreditType": "DEBIT"}) == -194320 # Debit card credit (debitCreditType=CREDIT) -> inflow (positive) assert _wf_to_ynab_millis({"transactionAmount": 225.0, "debitCreditType": "CREDIT"}) == 225000 # Pending (no debitCreditType) -> outflow (negative) assert _wf_to_ynab_millis({"transactionAmount": 2.48}) == -2480 print("test_wf_to_ynab_millis: PASS") def test_build_import_ids(): amounts = [-9110, -9110, -5000] dates = ["2026-03-18", "2026-03-18", "2026-03-17"] ids = _build_import_ids(amounts, dates) assert ids == [ "YNAB:-9110:2026-03-18:1", "YNAB:-9110:2026-03-18:2", "YNAB:-5000:2026-03-17:1", ], f"Got {ids}" print("test_build_import_ids: PASS") def test_format_amount_dollars(): assert _format_amount_dollars(-9110) == "$-9.11" assert _format_amount_dollars(225000) == "$+225.00" assert _format_amount_dollars(-194320) == "$-194.32" print("test_format_amount_dollars: PASS") def test_convert_wf_to_ynab_transactions(): wf_txns = [ { "transactionAmount": 9.11, "transactionDate": 1773817200000, "transactionDescription": "WALGREENS #10276 MORRISVILLE NC", "status": "POSTED", }, { "transactionAmount": 2.48, "transactionDate": 1774076400000, "transactionDescription": "CULVERS", "status": "PENDING", }, ] result = _convert_wf_to_ynab_transactions(wf_txns, "acct-123") assert result[0]["amount"] == -9110 assert result[0]["cleared"] == "cleared" assert result[0]["flag_color"] == "blue" assert result[0]["account_id"] == "acct-123" assert result[0]["payee_name"] == "WALGREENS #10276 MORRISVILLE NC" assert "YNAB:-9110:" in result[0]["import_id"] assert result[1]["amount"] == -2480 assert result[1]["cleared"] == "uncleared" assert result[1]["flag_color"] == "blue" print("test_convert_wf_to_ynab_transactions: PASS") def test_classify_for_import(): wf_txns = [ { "transactionAmount": 9.11, "transactionDate": 1773817200000, "transactionDescription": "WALGREENS #10276", "status": "POSTED", }, { "transactionAmount": 2.48, "transactionDate": 1774076400000, "transactionDescription": "CULVERS", "status": "PENDING", }, { "transactionAmount": 50.00, "transactionDate": 1773817200000, "transactionDescription": "NEW TXN", "status": "POSTED", }, ] ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123") # Derive dates to build matching YNAB transactions from main import _wf_date_to_iso date0 = _wf_date_to_iso(1773817200000) date1 = _wf_date_to_iso(1774076400000) ynab_txns = [ # Uncleared txn matching posted WALGREENS -> pending_to_posted { "id": "ynab-1", "date": date0, "payee_name": "WALGREENS", "category_name": None, "amount_milliCurrency": -9110, "memo": None, "account_id": "acct-123", "account_name": "Credit", "cleared": "uncleared", "import_id": "YNAB:-9110:" + date0 + ":1", }, # Cleared txn with same import_id as CULVERS -> duplicate { "id": "ynab-2", "date": date1, "payee_name": "CULVERS", "category_name": None, "amount_milliCurrency": -2480, "memo": None, "account_id": "acct-123", "account_name": "Credit", "cleared": "cleared", "import_id": "YNAB:-2480:" + date1 + ":1", }, ] classification = _classify_for_import(ynab_ready, ynab_txns) assert len(classification.pending_to_posted) == 1 assert classification.pending_to_posted[0][1]["id"] == "ynab-1" assert len(classification.duplicates) == 1 assert classification.duplicates[0]["flag_color"] == "red" assert len(classification.new) == 1 assert classification.new[0]["payee_name"] == "NEW TXN" print("test_classify_for_import: PASS") def test_preview(): """Visual test - just make sure it doesn't crash.""" wf_txns = [ {"transactionAmount": 9.11, "transactionDate": 1773817200000, "transactionDescription": "WALGREENS #10276", "status": "POSTED"}, {"transactionAmount": 50.00, "transactionDate": 1773817200000, "transactionDescription": "NEW TXN", "status": "POSTED"}, ] ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123") classification = _classify_for_import(ynab_ready, []) _preview_import(classification) print("test_preview: PASS") def _write_category_csv(path: Path, rows: list[tuple[str, str, str, str]]) -> None: with path.open("w", encoding="utf-8", newline="") as csv_file: writer = csv.writer(csv_file) writer.writerow(["payee", "category", "inflow", "outflow"]) writer.writerows(rows) def test_payee_normalization_and_amount_aware_categories(): amazon_a = "AMAZON MKTPLACE PMTS Amzn.com/billWA" amazon_b = "AMAZON MKTPL*BY44297W1 Amzn.com/billWA" normalized_a = normalize_payee(amazon_a) normalized_b = normalize_payee(amazon_b) assert "by44297w1" not in normalized_b assert normalized_a.startswith("amazon") assert normalized_b.startswith("amazon") assert normalize_payee("SHEETZ #1234") == normalize_payee("SHEETZ #5678") with tempfile.TemporaryDirectory() as temp_dir: csv_path = Path(temp_dir) / "transactions.csv" _write_category_csv(csv_path, [ (amazon_a, "Family", "", "50.00"), (amazon_a, "Family", "", "80.00"), ("SPLIT SHOP", "Family", "", "50.00"), ("SPLIT SHOP", "Dining Out", "", "20.00"), ("SHEETZ", "Gas", "", "40.00"), ("SHEETZ", "Gas", "", "40.00"), ("SHEETZ", "Dining Out", "", "2.00"), ("SHEETZ", "Dining Out", "", "2.00"), ("PUBLIX", "Groceries", "", "40.00"), ("PUBLIX", "Dining Out", "", "2.00"), ("REFUND SHOP", "Refunds", "12.00", ""), ("REFUND SHOP", "Shopping", "", "12.00"), ]) history = CategoryHistory.from_csv(csv_path) category_ids = { "family": ["family-id"], "gas": ["gas-id"], "dining out": ["dining-id"], "groceries": ["grocery-id"], "refunds": ["refund-id"], "shopping": ["shopping-id"], } wf_transactions = [ {"transactionAmount": 50.00, "transactionDate": 1773817200000, "transactionDescription": amazon_b, "status": "POSTED"}, {"transactionAmount": 40.00, "transactionDate": 1773817200000, "transactionDescription": "SHEETZ", "status": "POSTED"}, {"transactionAmount": 2.00, "transactionDate": 1773817200000, "transactionDescription": "SHEETZ", "status": "POSTED"}, {"transactionAmount": 2.00, "transactionDate": 1773817200000, "transactionDescription": "PUBLIX", "status": "POSTED"}, {"transactionAmount": 20.00, "transactionDate": 1773817200000, "transactionDescription": "SHEETZ", "status": "POSTED"}, {"transactionAmount": 12.00, "transactionDate": 1773817200000, "transactionDescription": "REFUND SHOP", "status": "POSTED", "debitCreditType": "CREDIT"}, ] ready = _convert_wf_to_ynab_transactions(wf_transactions, "account-id") _apply_category_suggestions(ready, history, category_ids) assert ready[0]["category_id"] == "family-id" assert ready[1]["category_id"] == "gas-id" assert ready[2]["category_id"] == "dining-id" assert ready[3]["category_id"] == "dining-id" assert "category_id" not in ready[4] assert ready[4]["_category_applied"] is False assert ready[5]["category_id"] == "refund-id" split_patterns = [ pattern for pattern in history.patterns.values() if pattern.payee == "split shop" ] assert {pattern.category for pattern in split_patterns} == {"Family", "Dining Out"} assert {pattern.amount_cents for pattern in split_patterns} == {2000, 5000} print("test_payee_normalization_and_amount_aware_categories: PASS") def test_category_memory_retains_and_merges_patterns(): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) csv_path = root / "transactions.csv" memory_path = CategoryHistory.memory_path_for_csv(csv_path) _write_category_csv(csv_path, [ ("OLD MERCHANT", "Family", "", "20.00"), ("OLD MERCHANT", "Family", "", "20.00"), ]) history = CategoryHistory.from_csv(csv_path) history.save(memory_path) _write_category_csv(csv_path, [ ("NEW MERCHANT", "Dining Out", "", "8.00"), ]) history = CategoryHistory.load(memory_path) latest_snapshot = CategoryHistory.from_csv(csv_path) history.merge(latest_snapshot) history.save(memory_path) copied_memory = root / "second_machine" / "category_memory.json" history.save(copied_memory) copied_history = CategoryHistory.load(copied_memory) patterns = copied_history.patterns assert any(pattern.payee == "old merchant" for pattern in patterns.values()) assert any(pattern.payee == "new merchant" for pattern in patterns.values()) assert next( pattern.count for pattern in patterns.values() if pattern.payee == "old merchant" ) == 2 print("test_category_memory_retains_and_merges_patterns: PASS") def test_split_export_becomes_separate_category_patterns(): with tempfile.TemporaryDirectory() as temp_dir: csv_path = Path(temp_dir) / "transactions.csv" rows_written = _write_transactions_csv([ { "id": "transaction-id", "date": "2026-03-18", "payee_name": "SPLIT SHOP", "category_name": "Split", "amount_milliCurrency": -70000, "memo": None, "account_id": "account-id", "account_name": "Credit Card", "cleared": "cleared", "import_id": None, "subtransactions": [ {"category_name": "Family", "amount_milliCurrency": -50000}, {"category_name": "Dining Out", "amount_milliCurrency": -20000}, ], }, ], csv_path) with csv_path.open("r", encoding="utf-8", newline="") as csv_file: rows = list(csv.DictReader(csv_file)) history = CategoryHistory.from_csv(csv_path) assert rows_written == 2 assert [row["payee"] for row in rows] == ["SPLIT SHOP", "SPLIT SHOP"] assert {row["category"] for row in rows} == {"Family", "Dining Out"} assert {pattern.amount_cents for pattern in history.patterns.values()} == {2000, 5000} print("test_split_export_becomes_separate_category_patterns: PASS") def test_ambiguous_category_name_is_not_assigned(): with tempfile.TemporaryDirectory() as temp_dir: csv_path = Path(temp_dir) / "transactions.csv" _write_category_csv(csv_path, [("COFFEE SHOP", "Dining", "", "5.00")]) history = CategoryHistory.from_csv(csv_path) suggestion = history.suggest( "COFFEE SHOP", -5000, {"dining": ["category-one", "category-two"]}, ) assert suggestion is not None assert suggestion.applied is False assert suggestion.category_id is None print("test_ambiguous_category_name_is_not_assigned: PASS") def test_missing_category_history_falls_back_empty(): with tempfile.TemporaryDirectory() as temp_dir: history, can_update_memory = _load_category_history( Path(temp_dir) / "missing.csv", report_empty=False, ) assert not history.patterns assert can_update_memory is True print("test_missing_category_history_falls_back_empty: PASS") def test_confident_category_is_sent_to_ynab(): account_id = "11111111-1111-4111-8111-111111111111" category_id = "22222222-2222-4222-8222-222222222222" classification = _ImportClassification() classification.new.append({ "account_id": account_id, "date": "2026-03-18", "amount": -5000, "payee_name": "COFFEE SHOP", "category_id": category_id, "cleared": "cleared", "flag_color": "blue", "import_id": "YNAB:-5000:2026-03-18:1", "approved": False, }) class FakeYNABClient: def __init__(self): self.transactions = [] def create_transactions(self, transactions): self.transactions = transactions return SimpleNamespace(transaction_ids=["created-id"], duplicate_import_ids=[]) client = FakeYNABClient() _execute_import(classification, client) assert str(client.transactions[0].category_id) == category_id print("test_confident_category_is_sent_to_ynab: PASS") if __name__ == "__main__": test_wf_to_ynab_millis() test_build_import_ids() test_format_amount_dollars() test_convert_wf_to_ynab_transactions() test_classify_for_import() test_preview() test_payee_normalization_and_amount_aware_categories() test_category_memory_retains_and_merges_patterns() test_split_export_becomes_separate_category_patterns() test_ambiguous_category_name_is_not_assigned() test_missing_category_history_falls_back_empty() test_confident_category_is_sent_to_ynab() print("\nALL TESTS PASSED")