From 2cf46646d8c7b1f99bfc32127bd00f03af12679b Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 18:40:14 +0300 Subject: [PATCH 01/33] Add .env options for debt tracking --- .env.example | 3 +++ main.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 251d163..d754ab0 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,6 @@ FIREFLY_DEFAULT_CATEGORY=Groceries FIREFLY_DRY_RUN=true SPLITWISE_DAYS=1 FOREIGN_CURRENCY_TOFIX_TAG=fixme/foreign-currency +# Debt tracker +SW_BALANCE_ACCOUNT=Splitwise # Leave blank to disable debt tracking +SW_BALANCE_DEFAULT_DESCRIPTION=Splitwise # Override expense description; leave blank to use expense description diff --git a/main.py b/main.py index 82a0a87..33eff5b 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,9 @@ def load_config() -> Config: "FIREFLY_DEFAULT_TRXFR_ACCOUNT": os.getenv("FIREFLY_DEFAULT_TRXFR_ACCOUNT", "Chase Checking"), "FIREFLY_DRY_RUN": bool(os.getenv("FIREFLY_DRY_RUN", True)), "SPLITWISE_DAYS": int(os.getenv("SPLITWISE_DAYS", 1)), - "FOREIGN_CURRENCY_TOFIX_TAG": os.getenv("FOREIGN_CURRENCY_TOFIX_TAG") + "FOREIGN_CURRENCY_TOFIX_TAG": os.getenv("FOREIGN_CURRENCY_TOFIX_TAG"), + "SW_BALANCE_ACCOUNT": os.getenv("SW_BALANCE_ACCOUNT", False), + "SW_BALANCE_DEFAULT_DESCRIPTION": os.getenv("SW_BALANCE_DEFAULT_DESCRIPTION", "Splitwise balance"), } time_now = datetime.now().astimezone() From 9916aeb3e3bf8663fd5e098516e890e0497fa8e9 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 21:36:58 +0300 Subject: [PATCH 02/33] Add loaded debt parameters to Config --- main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main.py b/main.py index 33eff5b..7e2c977 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,9 @@ class Config(TypedDict): FIREFLY_DEFAULT_TRXFR_ACCOUNT: str SPLITWISE_TOKEN: str SPLITWISE_DAYS: int + # Debt tracker + SW_BALANCE_ACCOUNT: str + SW_BALANCE_DEFAULT_DESCRIPTION: str def load_config() -> Config: load_dotenv() From 29b2771d1b609abc1633fea8f995064586b02fac Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:13:00 +0300 Subject: [PATCH 03/33] Create strategies skeleton --- strategies/__init__.py | 0 strategies/base.py | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 strategies/__init__.py create mode 100644 strategies/base.py diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strategies/base.py b/strategies/base.py new file mode 100644 index 0000000..2f64e8e --- /dev/null +++ b/strategies/base.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod +from splitwise import Expense +from splitwise.user import ExpenseUser + +class TransactionStrategy(ABC): + @abstractmethod + def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + pass \ No newline at end of file From 6460f6557d0c601600a37604ebba1be54fdfacd8 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:19:26 +0300 Subject: [PATCH 04/33] Create standard strategy --- strategies/standard.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 strategies/standard.py diff --git a/strategies/standard.py b/strategies/standard.py new file mode 100644 index 0000000..745cf2a --- /dev/null +++ b/strategies/standard.py @@ -0,0 +1,10 @@ +from .base import TransactionStrategy +from splitwise import Expense +from splitwise.user import ExpenseUser + +class StandardTransactionStrategy(TransactionStrategy): + def __init__(self, get_expense_transaction_body) -> None: + self._get_expense_transaction_body = get_expense_transaction_body + + def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + return [self._get_expense_transaction_body(exp, myshare, data)] \ No newline at end of file From aa4f62b78796b6e637951b0e3db42da5ecdaa5ad Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:37:39 +0300 Subject: [PATCH 05/33] Add splitwise balance strategy --- strategies/sw_balance.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 strategies/sw_balance.py diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py new file mode 100644 index 0000000..1fb82b9 --- /dev/null +++ b/strategies/sw_balance.py @@ -0,0 +1,28 @@ +from .base import TransactionStrategy +from splitwise import Expense +from splitwise.user import ExpenseUser + +class SWBalanceTransactionStrategy(TransactionStrategy): + def __init__(self, get_expense_transaction_body, sw_balance_account) -> None: + self._get_expense_transaction_body = get_expense_transaction_body + self._sw_balance_account = sw_balance_account + + def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + paid_txn = self._get_expense_transaction_body(exp, myshare, data, use_paid_amount=True) + + balance_txn = paid_txn.copy() + balance = myshare.getNetBalance() + if balance > 0: # I payed something and people owe me money; extra money goes to balance account + balance_txn['source_name'] = paid_txn['source_name'] + balance_txn['destination_name'] = self._sw_balance_account + balance_txn['amount'] = balance + balance_txn['type'] = 'transfer' + balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + else: # I payed less than what I owe; I payed the remaining amount from balance account + balance_txn['source_name'] = self._sw_balance_account + balance_txn['destination_name'] = paid_txn['destination_name'] + balance_txn['amount'] = -balance + balance_txn['type'] = "withdrawal" + balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + + return [paid_txn, balance_txn] \ No newline at end of file From 8b995463fa2d25a5d935baf4011616d6e958c0d0 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:38:16 +0300 Subject: [PATCH 06/33] Remove redundant option --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index d754ab0..4eb9102 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,3 @@ SPLITWISE_DAYS=1 FOREIGN_CURRENCY_TOFIX_TAG=fixme/foreign-currency # Debt tracker SW_BALANCE_ACCOUNT=Splitwise # Leave blank to disable debt tracking -SW_BALANCE_DEFAULT_DESCRIPTION=Splitwise # Override expense description; leave blank to use expense description From e920fc787bb999c2fd43dd4ff16b29dbd06f5b1b Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:44:45 +0300 Subject: [PATCH 07/33] Import and use strategy classes --- main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/main.py b/main.py index 7e2c977..ce1b2fe 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,10 @@ import os import requests +from strategies.standard import StandardTransactionStrategy +from strategies.sw_balance import SWBalanceTransactionStrategy +from strategies.base import TransactionStrategy + class Config(TypedDict): FIREFLY_URL: str FIREFLY_TOKEN: str @@ -385,6 +389,12 @@ def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Ex transaction["tags"].append(conf["FOREIGN_CURRENCY_TOFIX_TAG"]) return transaction +def get_transaction_strategy() -> TransactionStrategy: + if conf["SW_BALANCE_ACCOUNT"]: + return SWBalanceTransactionStrategy(getExpenseTransactionBody, conf["SW_BALANCE_ACCOUNT"]) + else: + return StandardTransactionStrategy(getExpenseTransactionBody) + def getAccounts(account_type: str="asset") -> list: """Get accounts from Firefly. From e5e987847c981d8cd768c09a34cd64e82432a53f Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:45:23 +0300 Subject: [PATCH 08/33] Use paid amount instead of owed on flag --- main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index ce1b2fe..1898b82 100644 --- a/main.py +++ b/main.py @@ -314,7 +314,7 @@ def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> return addTransaction(newTxn) -def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str]) -> dict: +def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid = False) -> dict: """ Get the transaction body for a Splitwise expense. :param exp: A Splitwise Expense object @@ -353,6 +353,7 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str if not processText(exp.getDetails()): notes = exp.getDetails() + amount = myshare.getPaidShare newTxn = { "source_name": source, "destination_name": dest, From a219c9712282345b577834734cad76ca2714b9be Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 22:57:27 +0300 Subject: [PATCH 09/33] Modify processExpense to use correct strategy --- main.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/main.py b/main.py index 1898b82..233804e 100644 --- a/main.py +++ b/main.py @@ -294,24 +294,33 @@ def addTransaction(newTxn: dict) -> None: def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> None: """ Process a Splitwise expense. Update or add a transaction on Firefly. + :param past_day: A datetime object. Expenses before this date are ignored. :param txns: A dictionary of transactions indexed by Splitwise external URL. :param exp: A Splitwise Expense object. :param args: A list of strings for Firefly fields. :return: None """ - newTxn: dict = getExpenseTransactionBody(exp, *args) - if oldTxnBody := txns.get(getSWUrlForExpense(exp)): - print("Updating...") - return updateTransaction(newTxn, oldTxnBody) - - if getDate(exp.getCreatedAt()) < past_day or getDate(exp.getDate()) < past_day: - if search := searchTransactions({"query": f'external_url_is:"{getSWUrlForExpense(exp)}"'}): - print("Updating old...") - # TODO(#1): This would have 2 results for same splitwise expense - return updateTransaction(newTxn, search[0]) - print("Adding...") - return addTransaction(newTxn) + + strategy = get_transaction_strategy() + new_txns: list[dict] = strategy.create_transactions(exp, *args) + for idx, new_txn in enumerate(new_txns): + external_url = getSWUrlForExpense(exp) + if idx > 0: + external_url += f"-balance_transfer-{idx}" + new_txn["external_url"] = external_url + + if oldTxnBody := txns.get(external_url): + print(f"Updating transaction {idx + 1}...") + updateTransaction(new_txn, oldTxnBody) + elif getDate(exp.getCreatedAt()) < past_day or getDate(exp.getDate()) < past_day: + if search := searchTransactions({"query": f'external_url_is:"{external_url}"'}): + print(f"Updating old transaction {idx + 1}...") + # TODO(#1): This would have 2 results for same splitwise expense + updateTransaction(new_txn, search[0]) + else: + print(f"Adding transaction {idx + 1}...") + addTransaction(new_txn) def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid = False) -> dict: From 2c30fa1211474edf4d851f4981f3bda7d9b5d91e Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 14 Oct 2024 23:11:11 +0300 Subject: [PATCH 10/33] Actually use paid amount on flag --- main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 233804e..fd5bee6 100644 --- a/main.py +++ b/main.py @@ -323,7 +323,7 @@ def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> addTransaction(new_txn) -def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid = False) -> dict: +def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid_amount = False) -> dict: """ Get the transaction body for a Splitwise expense. :param exp: A Splitwise Expense object @@ -362,7 +362,7 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str if not processText(exp.getDetails()): notes = exp.getDetails() - amount = myshare.getPaidShare + amount = myshare.getPaidShare if use_paid_amount else myshare.getOwedShare() newTxn = { "source_name": source, "destination_name": dest, From 72aa11aa55737c08229e16ff328bd43713581e7e Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Sat, 19 Oct 2024 09:38:04 +0300 Subject: [PATCH 11/33] Add strategy testing skeleton --- tests/test_strategies.py | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_strategies.py diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 0000000..dce4e43 --- /dev/null +++ b/tests/test_strategies.py @@ -0,0 +1,111 @@ +import pytest +from datetime import datetime +from unittest.mock import Mock, patch +from strategies.standard import StandardTransactionStrategy +from strategies.sw_balance import SWBalanceTransactionStrategy +from splitwise import Expense +from splitwise.user import ExpenseUser + +# Mock objects +mock_expense = Mock(spec=Expense) +mock_expense.getId.return_value = "123" +mock_expense.getDescription.return_value = "Test Expense" +mock_expense.getCurrencyCode.return_value = "USD" +mock_expense.getDate.return_value = "2023-05-01" +mock_expense.getCreatedAt.return_value = "2023-05-01T12:00:00Z" + +mock_user = Mock(spec=ExpenseUser) +mock_user.getId.return_value = "456" +mock_user.getOwedShare.return_value = "50.00" +mock_user.getPaidShare.return_value = "100.00" + +# Mock getExpenseTransactionBody function +def mock_get_expense_transaction_body(exp, myshare, data, use_paid_amount=False): + amount = myshare.getPaidShare() if use_paid_amount else myshare.getOwedShare() + return { + "amount": amount, + "description": exp.getDescription(), + "date": exp.getDate(), + "source_name": "Test Source", + "destination_name": "Test Destination", + "category_name": "Test Category", + "type": "withdrawal", + } + +# Tests for StandardTransactionStrategy +def test_standard_strategy(): + strategy = StandardTransactionStrategy(mock_get_expense_transaction_body) + transactions = strategy.create_transactions(mock_expense, mock_user, []) + + assert len(transactions) == 1 + assert transactions[0]["amount"] == "50.00" + assert transactions[0]["description"] == "Test Expense" + +# Tests for SWBalanceTransactionStrategy +def test_sw_balance_strategy(): + strategy = SWBalanceTransactionStrategy(mock_get_expense_transaction_body, "Splitwise Balance") + transactions = strategy.create_transactions(mock_expense, mock_user, []) + + assert len(transactions) == 2 + assert transactions[0]["amount"] == "100.00" + assert transactions[0]["description"] == "Test Expense" + assert transactions[1]["amount"] == "50.00" + assert transactions[1]["type"] == "transfer" + assert transactions[1]["destination_name"] == "Splitwise Balance" + +# Test for processExpense function +@patch('main.get_transaction_strategy') +@patch('main.updateTransaction') +@patch('main.addTransaction') +@patch('main.searchTransactions') +@patch('main.getSWUrlForExpense') +def test_process_expense(mock_get_url, mock_search, mock_add, mock_update, mock_get_strategy): + from main import processExpense, Config + + # Mock configuration + mock_config = Mock(spec=Config) + mock_config.USE_SW_BALANCE_ACCOUNT = True + + # Set up mock strategy + mock_strategy = Mock() + mock_strategy.create_transactions.return_value = [ + {"amount": "100.00", "description": "Test Expense"}, + {"amount": "50.00", "description": "Balance transfer for: Test Expense", "type": "transfer"} + ] + mock_get_strategy.return_value = mock_strategy + + # Set up other mocks + mock_get_url.return_value = "http://example.com/expense/123" + mock_search.return_value = [] + + # Call processExpense + processExpense(datetime.now(), {}, mock_expense, mock_user, []) + + # Assertions + assert mock_strategy.create_transactions.called + assert mock_add.call_count == 2 + assert mock_update.call_count == 0 + mock_add.assert_any_call({"amount": "100.00", "description": "Test Expense", "external_url": "http://example.com/expense/123"}) + mock_add.assert_any_call({"amount": "50.00", "description": "Balance transfer for: Test Expense", "type": "transfer", "external_url": "http://example.com/expense/123-balance-transfer-1"}) + +# Test for get_transaction_strategy function +@patch('requests.request') +def test_get_transaction_strategy(mock_request): + mock_request.return_value.json.return_value = {'data': []} + from main import get_transaction_strategy, Config + + # Test with USE_SW_BALANCE_ACCOUNT = False + mock_config = Mock(spec=Config) + mock_config.USE_SW_BALANCE_ACCOUNT = False + + with patch('main.conf', mock_config): + strategy = get_transaction_strategy() + assert isinstance(strategy, StandardTransactionStrategy) + + # Test with USE_SW_BALANCE_ACCOUNT = True + mock_config.USE_SW_BALANCE_ACCOUNT = True + mock_config.SW_BALANCE_ACCOUNT = "Splitwise Balance" + + with patch('main.conf', mock_config): + strategy = get_transaction_strategy() + assert isinstance(strategy, SWBalanceTransactionStrategy) \ No newline at end of file From d45b54c54dea0c2f5f7a2176e520f9fa717f1b0e Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Sun, 20 Oct 2024 23:14:09 +0300 Subject: [PATCH 12/33] Remove redundant option from main --- main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main.py b/main.py index fd5bee6..5b078e6 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,6 @@ class Config(TypedDict): SPLITWISE_DAYS: int # Debt tracker SW_BALANCE_ACCOUNT: str - SW_BALANCE_DEFAULT_DESCRIPTION: str def load_config() -> Config: load_dotenv() From 1a1d617f0f25da551772f13ae319faf20bb6fca8 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Sun, 20 Oct 2024 23:14:33 +0300 Subject: [PATCH 13/33] Convert str to float for comparison --- strategies/sw_balance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 1fb82b9..dbec264 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -12,7 +12,7 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn = paid_txn.copy() balance = myshare.getNetBalance() - if balance > 0: # I payed something and people owe me money; extra money goes to balance account + if float(balance) > 0: # I payed something and people owe me money; extra money goes to balance account balance_txn['source_name'] = paid_txn['source_name'] balance_txn['destination_name'] = self._sw_balance_account balance_txn['amount'] = balance From 9a3959b74d0e5be5337312b1c483f1b2437d328c Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 21 Oct 2024 00:00:30 +0300 Subject: [PATCH 14/33] minor strategy tests changes --- tests/test_strategies.py | 44 +++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/tests/test_strategies.py b/tests/test_strategies.py index dce4e43..834c6f2 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -16,8 +16,9 @@ mock_user = Mock(spec=ExpenseUser) mock_user.getId.return_value = "456" -mock_user.getOwedShare.return_value = "50.00" -mock_user.getPaidShare.return_value = "100.00" +mock_user.getOwedShare.return_value = "60.00" +mock_user.getPaidShare.return_value = "110.00" +mock_user.getNetBalance.return_value = "50.00" # Mock getExpenseTransactionBody function def mock_get_expense_transaction_body(exp, myshare, data, use_paid_amount=False): @@ -38,7 +39,7 @@ def test_standard_strategy(): transactions = strategy.create_transactions(mock_expense, mock_user, []) assert len(transactions) == 1 - assert transactions[0]["amount"] == "50.00" + assert transactions[0]["amount"] == "60.00" assert transactions[0]["description"] == "Test Expense" # Tests for SWBalanceTransactionStrategy @@ -47,30 +48,33 @@ def test_sw_balance_strategy(): transactions = strategy.create_transactions(mock_expense, mock_user, []) assert len(transactions) == 2 - assert transactions[0]["amount"] == "100.00" + assert transactions[0]["amount"] == "110.00" assert transactions[0]["description"] == "Test Expense" assert transactions[1]["amount"] == "50.00" assert transactions[1]["type"] == "transfer" assert transactions[1]["destination_name"] == "Splitwise Balance" # Test for processExpense function +@patch('main.getDate') @patch('main.get_transaction_strategy') @patch('main.updateTransaction') @patch('main.addTransaction') @patch('main.searchTransactions') @patch('main.getSWUrlForExpense') -def test_process_expense(mock_get_url, mock_search, mock_add, mock_update, mock_get_strategy): +def test_process_expense(mock_get_url, mock_search, mock_add, mock_update, mock_get_strategy, mock_get_date): from main import processExpense, Config # Mock configuration mock_config = Mock(spec=Config) - mock_config.USE_SW_BALANCE_ACCOUNT = True + mock_config.SW_BALANCE_ACCOUNT = "Splitwise Balance" + + mock_get_date.return_value = datetime.now().astimezone() # Make sure expense registers as new and not updated # Set up mock strategy mock_strategy = Mock() mock_strategy.create_transactions.return_value = [ - {"amount": "100.00", "description": "Test Expense"}, - {"amount": "50.00", "description": "Balance transfer for: Test Expense", "type": "transfer"} + {"amount": "110.00", "description": "Test Expense"}, + {"amount": "50.00", "description": "Balance transfer for: Test Expense"} ] mock_get_strategy.return_value = mock_strategy @@ -79,14 +83,18 @@ def test_process_expense(mock_get_url, mock_search, mock_add, mock_update, mock_ mock_search.return_value = [] # Call processExpense - processExpense(datetime.now(), {}, mock_expense, mock_user, []) + processExpense(datetime.now().astimezone(), {}, mock_expense, mock_user, []) # Assertions assert mock_strategy.create_transactions.called assert mock_add.call_count == 2 assert mock_update.call_count == 0 - mock_add.assert_any_call({"amount": "100.00", "description": "Test Expense", "external_url": "http://example.com/expense/123"}) - mock_add.assert_any_call({"amount": "50.00", "description": "Balance transfer for: Test Expense", "type": "transfer", "external_url": "http://example.com/expense/123-balance-transfer-1"}) + mock_add.assert_any_call({"amount": "110.00", + "description": "Test Expense", + "external_url": "http://example.com/expense/123"}) + mock_add.assert_any_call({"amount": "50.00", + "description": 'Balance transfer for: Test Expense', + "external_url": 'http://example.com/expense/123-balance_transfer-1'}) # Test for get_transaction_strategy function @patch('requests.request') @@ -94,18 +102,12 @@ def test_get_transaction_strategy(mock_request): mock_request.return_value.json.return_value = {'data': []} from main import get_transaction_strategy, Config - # Test with USE_SW_BALANCE_ACCOUNT = False - mock_config = Mock(spec=Config) - mock_config.USE_SW_BALANCE_ACCOUNT = False - - with patch('main.conf', mock_config): + # Test with SW_BALANCE_ACCOUNT = False + with patch.dict('main.conf', {'SW_BALANCE_ACCOUNT': ''}): strategy = get_transaction_strategy() assert isinstance(strategy, StandardTransactionStrategy) - # Test with USE_SW_BALANCE_ACCOUNT = True - mock_config.USE_SW_BALANCE_ACCOUNT = True - mock_config.SW_BALANCE_ACCOUNT = "Splitwise Balance" - - with patch('main.conf', mock_config): + # Test with SW_BALANCE_ACCOUNT = True + with patch.dict('main.conf', {'SW_BALANCE_ACCOUNT': 'Splitwise Balance'}): strategy = get_transaction_strategy() assert isinstance(strategy, SWBalanceTransactionStrategy) \ No newline at end of file From 3419f686e0a7daa21dad4ed908c21e04ab0cd309 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 21 Oct 2024 23:28:22 +0300 Subject: [PATCH 15/33] test bugfix --- tests/test_main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 8c9187b..26912cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -168,7 +168,9 @@ def test_processExpense_update(mock_getAccountCurrencyCode, @patch('main.addTransaction') @patch('main.searchTransactions') @patch('main.getAccountCurrencyCode') -def test_processExpense_add_new(mock_getAccountCurrencyCode, +@patch('main.getDate') +def test_processExpense_add_new(mock_getDate, + mock_getAccountCurrencyCode, mock_searchTransactions, mock_addTransaction, mock_updateTransaction, @@ -179,9 +181,11 @@ def test_processExpense_add_new(mock_getAccountCurrencyCode, mock_callApi.return_value = MagicMock(json=lambda: {}) mock_searchTransactions.return_value = [] mock_getAccountCurrencyCode.return_value = "USD" + mock_getDate.return_value = datetime.now().astimezone() ff_txns = {} - processExpense(datetime.now().astimezone() - timedelta(days=1), ff_txns, mock_expense, mock_expense_user, ["Dest", "Category", "Desc"]) + with patch.dict('main.conf', {'SW_BALANCE_ACCOUNT': ''}): + processExpense(datetime.now().astimezone() - timedelta(days=1), ff_txns, mock_expense, mock_expense_user, ["Dest", "Category", "Desc"]) mock_addTransaction.assert_called_once() mock_updateTransaction.assert_not_called() mock_searchTransactions.assert_called_once() From 371db3945209e7c6e5c8d1b60989b28b20731114 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 21 Oct 2024 23:40:27 +0300 Subject: [PATCH 16/33] Fix processExpense logic --- main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 5b078e6..4c26f00 100644 --- a/main.py +++ b/main.py @@ -312,14 +312,15 @@ def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> if oldTxnBody := txns.get(external_url): print(f"Updating transaction {idx + 1}...") updateTransaction(new_txn, oldTxnBody) - elif getDate(exp.getCreatedAt()) < past_day or getDate(exp.getDate()) < past_day: + continue + if getDate(exp.getCreatedAt()) < past_day or getDate(exp.getDate()) < past_day: if search := searchTransactions({"query": f'external_url_is:"{external_url}"'}): print(f"Updating old transaction {idx + 1}...") # TODO(#1): This would have 2 results for same splitwise expense updateTransaction(new_txn, search[0]) - else: - print(f"Adding transaction {idx + 1}...") - addTransaction(new_txn) + continue + print(f"Adding transaction {idx + 1}...") + addTransaction(new_txn) def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid_amount = False) -> dict: From a81f55d490c4face9f03351992ec54a9c458eb59 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 21 Oct 2024 23:42:51 +0300 Subject: [PATCH 17/33] Remove redundant patch --- tests/test_main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 26912cf..68e270d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -168,8 +168,7 @@ def test_processExpense_update(mock_getAccountCurrencyCode, @patch('main.addTransaction') @patch('main.searchTransactions') @patch('main.getAccountCurrencyCode') -@patch('main.getDate') -def test_processExpense_add_new(mock_getDate, +def test_processExpense_add_new( mock_getAccountCurrencyCode, mock_searchTransactions, mock_addTransaction, @@ -181,7 +180,6 @@ def test_processExpense_add_new(mock_getDate, mock_callApi.return_value = MagicMock(json=lambda: {}) mock_searchTransactions.return_value = [] mock_getAccountCurrencyCode.return_value = "USD" - mock_getDate.return_value = datetime.now().astimezone() ff_txns = {} with patch.dict('main.conf', {'SW_BALANCE_ACCOUNT': ''}): From 2b02d2383aa20e23e71b99a7f0e03aee6d84223e Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Tue, 22 Oct 2024 00:06:01 +0300 Subject: [PATCH 18/33] minor bugfixes --- .env.example | 2 +- main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4eb9102..9fbbd61 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ FIREFLY_DRY_RUN=true SPLITWISE_DAYS=1 FOREIGN_CURRENCY_TOFIX_TAG=fixme/foreign-currency # Debt tracker -SW_BALANCE_ACCOUNT=Splitwise # Leave blank to disable debt tracking +SW_BALANCE_ACCOUNT=Splitwise balance diff --git a/main.py b/main.py index 4c26f00..4dc23b7 100644 --- a/main.py +++ b/main.py @@ -362,7 +362,7 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str if not processText(exp.getDetails()): notes = exp.getDetails() - amount = myshare.getPaidShare if use_paid_amount else myshare.getOwedShare() + amount = myshare.getPaidShare() if use_paid_amount else myshare.getOwedShare() newTxn = { "source_name": source, "destination_name": dest, From c7771d58da6b8ae19da9519cf52a72e08a64b8de Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 23 Oct 2024 18:54:34 +0300 Subject: [PATCH 19/33] Remove use_payed_amount option from processExpense Handle it as one of the arguments to applyExpenseAmountToTransaction --- main.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 4dc23b7..f927062 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ from dotenv import load_dotenv from splitwise import Splitwise, Expense, User, Comment from splitwise.user import ExpenseUser -from typing import Generator, TypedDict +from typing import Generator, TypedDict, Union from functools import wraps import os @@ -323,7 +323,7 @@ def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> addTransaction(new_txn) -def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str], use_paid_amount = False) -> dict: +def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str]) -> dict: """ Get the transaction body for a Splitwise expense. :param exp: A Splitwise Expense object @@ -362,7 +362,6 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str if not processText(exp.getDetails()): notes = exp.getDetails() - amount = myshare.getPaidShare() if use_paid_amount else myshare.getOwedShare() newTxn = { "source_name": source, "destination_name": dest, @@ -381,15 +380,19 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str f"Processing {category} {formatExpense(exp, myshare)} from {source} to {dest}") return newTxn -def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: ExpenseUser) -> dict: +def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Union[ExpenseUser, float, str]) -> dict: """Apply the amount to the transaction based on the currency of the account. :param transaction: The transaction dictionary :param exp: The Splitwise expense - :param myshare: The user's share in the expense + :param myshare: The user's share in the expense. If a float, it is the transaction amount. :return: The updated transaction dictionary """ - amount = myshare.getOwedShare() + if isinstance(myshare, ExpenseUser): + amount = myshare.getOwedShare() + else: + amount = str(myshare) + if getAccountCurrencyCode(transaction["source_name"]) == exp.getCurrencyCode(): transaction["amount"] = amount else: From 61b2d6d912dd0c38852f0a58f051ad2c83b902c9 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 23 Oct 2024 18:56:40 +0300 Subject: [PATCH 20/33] Use applyExpenseAmountToTransaction in SW balance strategy --- main.py | 2 +- strategies/sw_balance.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index f927062..6b76876 100644 --- a/main.py +++ b/main.py @@ -404,7 +404,7 @@ def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Un def get_transaction_strategy() -> TransactionStrategy: if conf["SW_BALANCE_ACCOUNT"]: - return SWBalanceTransactionStrategy(getExpenseTransactionBody, conf["SW_BALANCE_ACCOUNT"]) + return SWBalanceTransactionStrategy(getExpenseTransactionBody, conf["SW_BALANCE_ACCOUNT"], applyExpenseAmountToTransaction) else: return StandardTransactionStrategy(getExpenseTransactionBody) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index dbec264..38709ab 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -3,26 +3,28 @@ from splitwise.user import ExpenseUser class SWBalanceTransactionStrategy(TransactionStrategy): - def __init__(self, get_expense_transaction_body, sw_balance_account) -> None: + def __init__(self, get_expense_transaction_body, sw_balance_account, apply_transaction_amount) -> None: self._get_expense_transaction_body = get_expense_transaction_body self._sw_balance_account = sw_balance_account + self._apply_transaction_amount = apply_transaction_amount def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: - paid_txn = self._get_expense_transaction_body(exp, myshare, data, use_paid_amount=True) + paid_txn = self._get_expense_transaction_body(exp, myshare, data) + paid_txn = self._apply_transaction_amount(paid_txn, exp, myshare.getPaidShare()) balance_txn = paid_txn.copy() balance = myshare.getNetBalance() if float(balance) > 0: # I payed something and people owe me money; extra money goes to balance account balance_txn['source_name'] = paid_txn['source_name'] balance_txn['destination_name'] = self._sw_balance_account - balance_txn['amount'] = balance balance_txn['type'] = 'transfer' balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) else: # I payed less than what I owe; I payed the remaining amount from balance account balance_txn['source_name'] = self._sw_balance_account balance_txn['destination_name'] = paid_txn['destination_name'] - balance_txn['amount'] = -balance balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + balance_txn = self._apply_transaction_amount(balance_txn, exp, -balance) return [paid_txn, balance_txn] \ No newline at end of file From 3910040b0a0cd2984600ba8d0581ac8fe9bd96cd Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 6 Nov 2024 18:16:46 +0200 Subject: [PATCH 21/33] Correctly make a deposit to the balance account Make a deposit to the SW balance account of amount "balance" when an expense entails people owe me money, instead of a transfer from the paying account to the SW balance, which doesn't make sense. --- strategies/sw_balance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 38709ab..877d38e 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -15,9 +15,9 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn = paid_txn.copy() balance = myshare.getNetBalance() if float(balance) > 0: # I payed something and people owe me money; extra money goes to balance account - balance_txn['source_name'] = paid_txn['source_name'] + balance_txn['source_name'] = self._sw_balance_account + " balancer" balance_txn['destination_name'] = self._sw_balance_account - balance_txn['type'] = 'transfer' + balance_txn['type'] = 'deposit' balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) else: # I payed less than what I owe; I payed the remaining amount from balance account From b76dface62b10e6191ac133373eb5116f9edea2d Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 6 Nov 2024 18:47:45 +0200 Subject: [PATCH 22/33] bug: handle deposit type txns If type is "deposit", the asset account is the *destination*, not the *source*. --- main.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 6b76876..3b418dd 100644 --- a/main.py +++ b/main.py @@ -393,7 +393,13 @@ def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Un else: amount = str(myshare) - if getAccountCurrencyCode(transaction["source_name"]) == exp.getCurrencyCode(): + if transaction['type'] in ["withdrawal", "transfer"]: + determiner_account = transaction['source_name'] + elif transaction['type'] == "deposit": + determiner_account = transaction['destination_name'] + else: + raise NotImplementedError(f"Transaction type {transaction['type']} not implemented.") + if getAccountCurrencyCode(determiner_account) == exp.getCurrencyCode(): transaction["amount"] = amount else: transaction["foreign_currency_code"] = exp.getCurrencyCode() From 525d90c0d8b741c2c34396c347af5579241363b1 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Sat, 9 Nov 2024 15:27:41 +0200 Subject: [PATCH 23/33] convert str to float for taking negative --- strategies/sw_balance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 877d38e..6c54440 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -25,6 +25,6 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn['destination_name'] = paid_txn['destination_name'] balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" - balance_txn = self._apply_transaction_amount(balance_txn, exp, -balance) + balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) return [paid_txn, balance_txn] \ No newline at end of file From 4fb84d555b28830c503dffc3342976bb24217bf1 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Sat, 9 Nov 2024 15:37:59 +0200 Subject: [PATCH 24/33] handle expenses by others If I payed nothing on an expense, don't make a transaction from my real accounts, only from the balance account. --- strategies/sw_balance.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 6c54440..e39f35a 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -26,5 +26,7 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) - - return [paid_txn, balance_txn] \ No newline at end of file + txns = [paid_txn, balance_txn] + if float(paid_txn['amount']) == 0: # I payed nothing; only balance transaction is needed + txns = [balance_txn] + return txns \ No newline at end of file From 7d302d913861628003615e8da623d39207c1e904 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 11 Nov 2024 21:15:26 +0200 Subject: [PATCH 25/33] unify comment style --- strategies/sw_balance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index e39f35a..7857067 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -14,19 +14,19 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn = paid_txn.copy() balance = myshare.getNetBalance() - if float(balance) > 0: # I payed something and people owe me money; extra money goes to balance account + if float(balance) > 0: # I paid, I am owed; difference credited to balance account balance_txn['source_name'] = self._sw_balance_account + " balancer" balance_txn['destination_name'] = self._sw_balance_account balance_txn['type'] = 'deposit' balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) - else: # I payed less than what I owe; I payed the remaining amount from balance account + else: # I paid, I owe; difference debited from balance account balance_txn['source_name'] = self._sw_balance_account balance_txn['destination_name'] = paid_txn['destination_name'] balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) txns = [paid_txn, balance_txn] - if float(paid_txn['amount']) == 0: # I payed nothing; only balance transaction is needed + if float(paid_txn['amount']) == 0: # I didn't pay, I owe; debit from balance account (only one txn) txns = [balance_txn] return txns \ No newline at end of file From f0730e611337cca71466267aa33e70f7836e5ee7 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 11 Nov 2024 23:55:53 +0200 Subject: [PATCH 26/33] refactor for clarity explicitly handle expenses where I paid nothing but I still owe something, and the edge case where an expense was recorded but I paid my whole share. --- strategies/sw_balance.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 7857067..4b15634 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -9,24 +9,26 @@ def __init__(self, get_expense_transaction_body, sw_balance_account, apply_trans self._apply_transaction_amount = apply_transaction_amount def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + txns = {} paid_txn = self._get_expense_transaction_body(exp, myshare, data) paid_txn = self._apply_transaction_amount(paid_txn, exp, myshare.getPaidShare()) + if float(paid_txn['amount']) != 0: # I paid; payment txn needed + txns['paid'] = paid_txn balance_txn = paid_txn.copy() balance = myshare.getNetBalance() - if float(balance) > 0: # I paid, I am owed; difference credited to balance account - balance_txn['source_name'] = self._sw_balance_account + " balancer" - balance_txn['destination_name'] = self._sw_balance_account - balance_txn['type'] = 'deposit' - balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" - balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) - else: # I paid, I owe; difference debited from balance account - balance_txn['source_name'] = self._sw_balance_account - balance_txn['destination_name'] = paid_txn['destination_name'] - balance_txn['type'] = "withdrawal" - balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" - balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) - txns = [paid_txn, balance_txn] - if float(paid_txn['amount']) == 0: # I didn't pay, I owe; debit from balance account (only one txn) - txns = [balance_txn] + if float(balance) != 0: # I owe or am owed; balance txn needed + txns['balance'] = balance_txn + if float(balance) > 0: # I am owed; difference credited to balance account + balance_txn['source_name'] = self._sw_balance_account + " balancer" + balance_txn['destination_name'] = self._sw_balance_account + balance_txn['type'] = 'deposit' + balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) + else: # I owe; difference debited from balance account + balance_txn['source_name'] = self._sw_balance_account + balance_txn['destination_name'] = paid_txn['destination_name'] + balance_txn['type'] = "withdrawal" + balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" + balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) return txns \ No newline at end of file From f7e6196329346512519faca1d69fe986a4dd6f45 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Mon, 11 Nov 2024 23:56:57 +0200 Subject: [PATCH 27/33] flatten dictionary to comply with signature --- strategies/sw_balance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index 4b15634..d51d915 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -31,4 +31,4 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) - return txns \ No newline at end of file + return list(txns.values()) \ No newline at end of file From 4522c2d23ffc71a37b8ea81bdd2335b0d64b0af2 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Tue, 12 Nov 2024 00:23:04 +0200 Subject: [PATCH 28/33] clarify amount application method usage Remove ability to pass ExpenseUser to get amount, pass amount explicitly. Rename method to clarify usage. --- main.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 3b418dd..e316dc8 100644 --- a/main.py +++ b/main.py @@ -375,31 +375,28 @@ def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str "external_url": getSWUrlForExpense(exp), "tags": [], } - newTxn = applyExpenseAmountToTransaction(newTxn, exp, myshare) + newTxn = applyAmountToTransaction(newTxn, exp, myshare.getOwedShare()) print( f"Processing {category} {formatExpense(exp, myshare)} from {source} to {dest}") return newTxn -def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Union[ExpenseUser, float, str]) -> dict: +def applyAmountToTransaction(transaction: dict, exp: Expense, amount: float) -> dict: """Apply the amount to the transaction based on the currency of the account. :param transaction: The transaction dictionary :param exp: The Splitwise expense - :param myshare: The user's share in the expense. If a float, it is the transaction amount. + :param amount: The amount to apply :return: The updated transaction dictionary """ - if isinstance(myshare, ExpenseUser): - amount = myshare.getOwedShare() - else: - amount = str(myshare) + amount = str(float(amount)) if transaction['type'] in ["withdrawal", "transfer"]: - determiner_account = transaction['source_name'] + account_to_check = transaction['source_name'] elif transaction['type'] == "deposit": - determiner_account = transaction['destination_name'] + account_to_check = transaction['destination_name'] else: raise NotImplementedError(f"Transaction type {transaction['type']} not implemented.") - if getAccountCurrencyCode(determiner_account) == exp.getCurrencyCode(): + if getAccountCurrencyCode(account_to_check) == exp.getCurrencyCode(): transaction["amount"] = amount else: transaction["foreign_currency_code"] = exp.getCurrencyCode() @@ -410,7 +407,7 @@ def applyExpenseAmountToTransaction(transaction: dict, exp: Expense, myshare: Un def get_transaction_strategy() -> TransactionStrategy: if conf["SW_BALANCE_ACCOUNT"]: - return SWBalanceTransactionStrategy(getExpenseTransactionBody, conf["SW_BALANCE_ACCOUNT"], applyExpenseAmountToTransaction) + return SWBalanceTransactionStrategy(getExpenseTransactionBody, conf["SW_BALANCE_ACCOUNT"], applyAmountToTransaction) else: return StandardTransactionStrategy(getExpenseTransactionBody) From bf398ac9e2a7328caa7b68f59129565ecc580452 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Tue, 12 Nov 2024 00:23:56 +0200 Subject: [PATCH 29/33] minor refactor --- strategies/sw_balance.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py index d51d915..eeda7aa 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -16,10 +16,10 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str txns['paid'] = paid_txn balance_txn = paid_txn.copy() - balance = myshare.getNetBalance() - if float(balance) != 0: # I owe or am owed; balance txn needed + balance = float(myshare.getNetBalance()) + if balance != 0: # I owe or am owed; balance txn needed txns['balance'] = balance_txn - if float(balance) > 0: # I am owed; difference credited to balance account + if balance > 0: # I am owed; difference credited to balance account balance_txn['source_name'] = self._sw_balance_account + " balancer" balance_txn['destination_name'] = self._sw_balance_account balance_txn['type'] = 'deposit' @@ -30,5 +30,5 @@ def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str balance_txn['destination_name'] = paid_txn['destination_name'] balance_txn['type'] = "withdrawal" balance_txn['description'] = f"Balance transfer for: {paid_txn['description']}" - balance_txn = self._apply_transaction_amount(balance_txn, exp, -float(balance)) + balance_txn = self._apply_transaction_amount(balance_txn, exp, -balance) return list(txns.values()) \ No newline at end of file From 8cd582240d7426126fa7d574dda353b19b65034d Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 13 Nov 2024 09:38:38 +0200 Subject: [PATCH 30/33] fix test_sw_balance_strategy Correctly use mock_apply_transaction_amount --- tests/test_strategies.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 834c6f2..2b3987d 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -21,8 +21,8 @@ mock_user.getNetBalance.return_value = "50.00" # Mock getExpenseTransactionBody function -def mock_get_expense_transaction_body(exp, myshare, data, use_paid_amount=False): - amount = myshare.getPaidShare() if use_paid_amount else myshare.getOwedShare() +def mock_get_expense_transaction_body(exp, myshare, data): + amount = myshare.getOwedShare() return { "amount": amount, "description": exp.getDescription(), @@ -33,6 +33,10 @@ def mock_get_expense_transaction_body(exp, myshare, data, use_paid_amount=False) "type": "withdrawal", } +def mock_apply_transaction_amount(txn, exp, amount): + txn['amount'] = str(amount) + return txn + # Tests for StandardTransactionStrategy def test_standard_strategy(): strategy = StandardTransactionStrategy(mock_get_expense_transaction_body) @@ -44,14 +48,14 @@ def test_standard_strategy(): # Tests for SWBalanceTransactionStrategy def test_sw_balance_strategy(): - strategy = SWBalanceTransactionStrategy(mock_get_expense_transaction_body, "Splitwise Balance") + strategy = SWBalanceTransactionStrategy(mock_get_expense_transaction_body, "Splitwise Balance", mock_apply_transaction_amount) transactions = strategy.create_transactions(mock_expense, mock_user, []) assert len(transactions) == 2 assert transactions[0]["amount"] == "110.00" assert transactions[0]["description"] == "Test Expense" - assert transactions[1]["amount"] == "50.00" - assert transactions[1]["type"] == "transfer" + assert float(transactions[1]["amount"]) == float("50.00") + assert transactions[1]["type"] == "deposit" assert transactions[1]["destination_name"] == "Splitwise Balance" # Test for processExpense function From a6aa87b99ebb09c3445ed4f49b9a8bff0837c4fc Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 13 Nov 2024 09:40:26 +0200 Subject: [PATCH 31/33] Make test robust to trailing zeros --- tests/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 68e270d..aba0d35 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -136,7 +136,7 @@ def test_getExpenseTransactionBody(mock_getAccountCurrencyCode, mock_expense, mo assert result["source_name"] == "Amex" assert result["destination_name"] == "Dest" assert result["category_name"] == "Category" - assert result["amount"] == "10.00" + assert float(result["amount"]) == float("10.00") assert result["description"] == "Desc" @patch('main.callApi') From 0b4fa01c3827c4771a80c7260d6f3d2d2d88b612 Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 13 Nov 2024 09:46:01 +0200 Subject: [PATCH 32/33] Disable splitwise balance feature in test --- tests/test_main.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index aba0d35..6d388b8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -151,17 +151,19 @@ def test_processExpense_update(mock_getAccountCurrencyCode, mock_callApi, mock_expense, mock_expense_user): - processExpense = load_main().processExpense - getSWUrlForExpense = load_main().getSWUrlForExpense - - mock_getAccountCurrencyCode.return_value = "USD" - mock_callApi.return_value = MagicMock(json=lambda: {}) - mock_searchTransactions.return_value = [] - - ff_txns = {getSWUrlForExpense(mock_expense): {"id": "123", "attributes": {}}} - processExpense(datetime.now().astimezone() - timedelta(days=1), ff_txns, mock_expense, mock_expense_user, []) - mock_updateTransaction.assert_called_once() - mock_addTransaction.assert_not_called() + from main import Config + with patch.dict('main.conf', {'SW_BALANCE_ACCOUNT': ''}): + processExpense = load_main().processExpense + getSWUrlForExpense = load_main().getSWUrlForExpense + + mock_getAccountCurrencyCode.return_value = "USD" + mock_callApi.return_value = MagicMock(json=lambda: {}) + mock_searchTransactions.return_value = [] + + ff_txns = {getSWUrlForExpense(mock_expense): {"id": "123", "attributes": {}}} + processExpense(datetime.now().astimezone() - timedelta(days=1), ff_txns, mock_expense, mock_expense_user, []) + mock_updateTransaction.assert_called_once() + mock_addTransaction.assert_not_called() @patch('main.callApi') @patch('main.updateTransaction') From bc5899599a4f34b6b3c0cb37cfab8995d17ad5dd Mon Sep 17 00:00:00 2001 From: "LAPTOP-8G8AGL8G\\yotam" Date: Wed, 13 Nov 2024 09:57:40 +0200 Subject: [PATCH 33/33] Extend README to describe debt tracking --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index b0683e0..bc0139c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ Set these variables either in the environment or a `.env` file along with the sc 6. `FIREFLY_DEFAULT_CATEGORY`: Set the default category to use. If empty, falls back to the Splitwise category. 7. `FIREFLY_DRY_RUN`: Set this to any value to dry run and skip the firefly API call. 8. `SPLITWISE_DAYS=1` +9. `SW_BALANCE_ACCOUNT=Splitwise balance`: Set this to the name of the virtual Splitwise balance asset account on Firefly to enable the debt tracking feature. + +## Debt tracking feature +When enabled, tracks Splitwise payable and receivable debts in an account defined by `SW_BALANCE_ACCOUNT`. + +For example, assume you paid 100$ but your share was only 40$. Splitwise records correctly that you are owed 60$ - so your total assets haven't really decreased by 100$, only by 40$. Enabling this feature correctly tracks this in Firefly, without compromising on recording the real 100$ transaction you will see in your bank statement. + +For each Splitwise expense, create two Firefly transactions: +1. A withdrawal from a real account, recording the real amount of money paid in the expense +2. A deposit to the `SW_BALANCE_ACCOUNT` equal the difference between the amount paid and the amount owed. ## Note/Comment format