294 lines
8.2 KiB
Python
294 lines
8.2 KiB
Python
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'}
|