add transaction CSV export and category suggestion features

This commit is contained in:
2026-10-08 16:48:03 -04:00
parent c23b8e8ef0
commit 0a1c5b2838
7 changed files with 810 additions and 18 deletions
+218 -1
View File
@@ -1,7 +1,16 @@
"""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,
_classify_for_import, _format_amount_dollars, _preview_import,
_ImportClassification, _apply_category_suggestions, _classify_for_import,
_execute_import, _format_amount_dollars, _load_category_history,
_preview_import,
_write_transactions_csv,
)
@@ -141,6 +150,208 @@ def test_preview():
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()
@@ -148,5 +359,11 @@ if __name__ == "__main__":
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")