from datetime import date from unittest.mock import MagicMock import pytest from ynab import TransactionFlagColor from catnab.ai import ItemClassification import catnab from catnab import Context, TxCategory from catnab.amazon import AmazonAccount def test_is_matchable_ynab(mk_ynab_tx): matchable = mk_ynab_tx() assert catnab.is_matchable_ynab(matchable) wrong_payee = mk_ynab_tx(payee_name='Walmart') assert not catnab.is_matchable_ynab(wrong_payee) already_matched = mk_ynab_tx(flag_color=TransactionFlagColor.PURPLE) assert not catnab.is_matchable_ynab(already_matched) already_approved = mk_ynab_tx(approved=True) assert not catnab.is_matchable_ynab(already_approved) # fortunately we only need to check the length of the subtransactions list already_split = mk_ynab_tx(subtransactions=[1, 2, 3]) assert not catnab.is_matchable_ynab(already_split) def test_is_matchable_amz(mk_amz_tx): matchable = mk_amz_tx() assert catnab.is_matchable_amz(matchable) refund = mk_amz_tx(is_refund=True) assert not catnab.is_matchable_amz(refund) empty_order_number = mk_amz_tx(order_number='') assert not catnab.is_matchable_amz(empty_order_number) gift_card_order=mk_amz_tx(payment_method='Amazon Gift Card') assert not catnab.is_matchable_amz(gift_card_order) points_order = mk_amz_tx(payment_method='Amazon Visa points') assert not catnab.is_matchable_amz(points_order) def test_tx_matches(mk_amz_tx, mk_ynab_tx): amz_tx = mk_amz_tx( grand_total=123.45, completed_date=date(2025, 1, 1), ) ynab_tx=mk_ynab_tx( amount=123450, var_date=date(2025, 1, 1), ) matches = catnab.tx_matches([amz_tx], [ynab_tx]) assert len(matches) == 1 def test_tx_matches_max_datediff(mk_amz_tx, mk_ynab_tx): # with a 7-day difference, transactions match amz_tx = mk_amz_tx( grand_total=123.45, completed_date=date(2025, 1, 1), ) ynab_tx=mk_ynab_tx( amount=123450, var_date=date(2025, 1, 8), ) matches = catnab.tx_matches([amz_tx], [ynab_tx]) assert len(matches) == 1 # but with an 8-day difference, they don't ynab_tx.var_date = date(2026, 1, 9) matches = catnab.tx_matches([amz_tx], [ynab_tx]) assert len(matches) == 0 def test_tx_matches_tiebreak(mk_amz_tx, mk_ynab_tx): amz_tx = mk_amz_tx( grand_total=123.45, completed_date=date(2025, 1, 1), ) ynab_1 = mk_ynab_tx( amount=123450, var_date=date(2025, 1, 5), ) ynab_2 = mk_ynab_tx( amount=123450, var_date=date(2025, 1, 3), ) matches = catnab.tx_matches([amz_tx], [ynab_1, ynab_2]) assert len(matches) == 1 matched_amz, matched_ynab = matches[0] assert matched_ynab is ynab_2 def test_get_tx_categories(monkeypatch): order = MagicMock( items=[ MagicMock( title='An example item', price=17.49 ) ] ) item_details={ 'An example item': ItemClassification( name='Example item', category='Example category', description='example', ) } def classify(_names, _categories): return item_details monkeypatch.setattr('catnab.ai.classify', classify) res = catnab.get_tx_categories(order, item_details) assert len(res) == 1 assert res[0].category_name == 'Example category' assert res[0].description == 'example' assert res[0].ratio == 1.0 def test_get_categories_multi_items(monkeypatch): order = MagicMock( items=[ MagicMock( title='An example item', price=17.49 ), MagicMock( title='Another example', price=12.34, ) ] ) item_details={ 'An example item': ItemClassification( name='Example item 1', category='Example category', description='example', ), 'Another example': ItemClassification( name='Example item 2', category='Example category', description='second example', ) } def classify(_names, _categories): return item_details monkeypatch.setattr('catnab.ai.classify', classify) res = catnab.get_tx_categories(order, item_details) assert len(res) == 1 assert res[0].category_name == 'Example category' assert res[0].description == 'example, second example' assert res[0].ratio == 1.0 def test_get_categories_multi_categories(monkeypatch): order = MagicMock( items=[ MagicMock( title='An example item', price=17.49 ), MagicMock( title='Another example', price=12.34, ) ] ) item_details={ 'An example item': ItemClassification( name='Example item 1', category='Example category', description='example', ), 'Another example': ItemClassification( name='Example item 2', category='Other category', description='second example', ) } def classify(_names, _categories): return item_details monkeypatch.setattr('catnab.ai.classify', classify) res = catnab.get_tx_categories(order, item_details) assert len(res) == 2 assert res[0].category_name == 'Example category' assert res[0].description == 'example' assert res[0].ratio == pytest.approx(0.59, abs=0.01) assert res[1].category_name == 'Other category' assert res[1].description == 'second example' assert res[1].ratio == pytest.approx(0.41, abs=0.01) def test_build_tx_update_multi(): tx_categories = [ TxCategory( category_name='Example category', description='example', ratio=0.68, ), TxCategory( category_name='Other category', description='second example', ratio=0.32, ) ] category_ids = { 'Example category': '123', 'Other category': '456', } tx = MagicMock( id='958e13e6-7bfd-465e-956a-887f15c8c456', # ynab uses tenths of a cent as its unit amount=23760, ) res = catnab.build_tx_update(tx_categories, category_ids, tx) assert res.subtransactions is not None and len(res.subtransactions) == 2 sub0 = res.subtransactions[0] assert sub0.category_id == '123' assert sub0.memo == 'example' assert sub0.amount == 16157 sub1 = res.subtransactions[1] assert sub1.category_id == '456' assert sub1.memo == 'second example' assert sub1.amount == 7603 def test_sync_acct_basic(basic_order, ynab_creds): ctx = Context(sync_days=30) acct = AmazonAccount('test@example.com', 'password123') catnab.sync_account(ctx, acct) mock_update = ctx.ynab_client.update_transactions mock_update.assert_called_once() tx_update = mock_update.call_args.args[0][0] assert tx_update.id == '1' assert tx_update.category_id == '0' assert tx_update.flag_color == 'purple' def test_sync_acct_multi_items(multi_item_order, ynab_creds): ctx = Context(sync_days=30) acct = AmazonAccount('test@example.com', 'password123') catnab.sync_account(ctx, acct) mock_update = ctx.ynab_client.update_transactions mock_update.assert_called_once() tx_update = mock_update.call_args.args[0][0] assert tx_update.id == '1' assert tx_update.category_id == '0' assert tx_update.flag_color == 'purple' def test_sync_acct_multi_categories(multi_category_order, ynab_creds): ctx = Context(sync_days=30) acct = AmazonAccount('test@example.com', 'password123') catnab.sync_account(ctx, acct) mock_update = ctx.ynab_client.update_transactions mock_update.assert_called_once() tx_update = mock_update.call_args.args[0][0] assert tx_update.id == '1' assert tx_update.category_id == None assert tx_update.flag_color == 'purple' assert len(tx_update.subtransactions) == 2 # we don't care about order, but both categories need to be present assert set(st.category_id for st in tx_update.subtransactions) == {'0', '1'}