370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""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")
|
|
|