From 902951e3355c25e5b30f38dd510809c842859789 Mon Sep 17 00:00:00 2001 From: Sha-yol <32091466+Sha-yol@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:07:09 +0200 Subject: [PATCH] Debt tracker (#23) * Add .env options for debt tracking * Add loaded debt parameters to Config * Create strategies skeleton * Create standard strategy * Add splitwise balance strategy * Remove redundant option * Import and use strategy classes * Use paid amount instead of owed on flag * Modify processExpense to use correct strategy * Actually use paid amount on flag * Add strategy testing skeleton * Remove redundant option from main * Convert str to float for comparison * minor strategy tests changes * test bugfix * Fix processExpense logic * Remove redundant patch * minor bugfixes * Remove use_payed_amount option from processExpense Handle it as one of the arguments to applyExpenseAmountToTransaction * Use applyExpenseAmountToTransaction in SW balance strategy * 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. * bug: handle deposit type txns If type is "deposit", the asset account is the *destination*, not the *source*. * convert str to float for taking negative * 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. * unify comment style * 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. * flatten dictionary to comply with signature * clarify amount application method usage Remove ability to pass ExpenseUser to get amount, pass amount explicitly. Rename method to clarify usage. * minor refactor * fix test_sw_balance_strategy Correctly use mock_apply_transaction_amount * Make test robust to trailing zeros * Disable splitwise balance feature in test * Extend README to describe debt tracking --- .env.example | 2 + README.md | 10 ++++ main.py | 67 ++++++++++++++++------ strategies/__init__.py | 0 strategies/base.py | 8 +++ strategies/standard.py | 10 ++++ strategies/sw_balance.py | 34 ++++++++++++ tests/test_main.py | 32 ++++++----- tests/test_strategies.py | 117 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 248 insertions(+), 32 deletions(-) create mode 100644 strategies/__init__.py create mode 100644 strategies/base.py create mode 100644 strategies/standard.py create mode 100644 strategies/sw_balance.py create mode 100644 tests/test_strategies.py diff --git a/.env.example b/.env.example index 251d163..9fbbd61 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ FIREFLY_DEFAULT_CATEGORY=Groceries FIREFLY_DRY_RUN=true SPLITWISE_DAYS=1 FOREIGN_CURRENCY_TOFIX_TAG=fixme/foreign-currency +# Debt tracker +SW_BALANCE_ACCOUNT=Splitwise balance 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 diff --git a/main.py b/main.py index 82a0a87..e316dc8 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,16 @@ 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 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 @@ -17,6 +21,8 @@ class Config(TypedDict): FIREFLY_DEFAULT_TRXFR_ACCOUNT: str SPLITWISE_TOKEN: str SPLITWISE_DAYS: int + # Debt tracker + SW_BALANCE_ACCOUNT: str def load_config() -> Config: load_dotenv() @@ -29,7 +35,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() @@ -285,24 +293,34 @@ 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) + 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]) + continue + print(f"Adding transaction {idx + 1}...") + addTransaction(new_txn) def getExpenseTransactionBody(exp: Expense, myshare: ExpenseUser, data: list[str]) -> dict: @@ -357,21 +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: ExpenseUser) -> 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 + :param amount: The amount to apply :return: The updated transaction dictionary """ - amount = myshare.getOwedShare() - if getAccountCurrencyCode(transaction["source_name"]) == exp.getCurrencyCode(): + amount = str(float(amount)) + + if transaction['type'] in ["withdrawal", "transfer"]: + account_to_check = transaction['source_name'] + elif transaction['type'] == "deposit": + account_to_check = transaction['destination_name'] + else: + raise NotImplementedError(f"Transaction type {transaction['type']} not implemented.") + if getAccountCurrencyCode(account_to_check) == exp.getCurrencyCode(): transaction["amount"] = amount else: transaction["foreign_currency_code"] = exp.getCurrencyCode() @@ -380,6 +405,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"], applyAmountToTransaction) + else: + return StandardTransactionStrategy(getExpenseTransactionBody) + def getAccounts(account_type: str="asset") -> list: """Get accounts from Firefly. 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 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 diff --git a/strategies/sw_balance.py b/strategies/sw_balance.py new file mode 100644 index 0000000..eeda7aa --- /dev/null +++ b/strategies/sw_balance.py @@ -0,0 +1,34 @@ +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, 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]: + 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 = float(myshare.getNetBalance()) + if balance != 0: # I owe or am owed; balance txn needed + txns['balance'] = balance_txn + 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' + 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, -balance) + return list(txns.values()) \ No newline at end of file diff --git a/tests/test_main.py b/tests/test_main.py index 8c9187b..6d388b8 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') @@ -151,24 +151,27 @@ 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') @patch('main.addTransaction') @patch('main.searchTransactions') @patch('main.getAccountCurrencyCode') -def test_processExpense_add_new(mock_getAccountCurrencyCode, +def test_processExpense_add_new( + mock_getAccountCurrencyCode, mock_searchTransactions, mock_addTransaction, mock_updateTransaction, @@ -181,7 +184,8 @@ def test_processExpense_add_new(mock_getAccountCurrencyCode, mock_getAccountCurrencyCode.return_value = "USD" 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() diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 0000000..2b3987d --- /dev/null +++ b/tests/test_strategies.py @@ -0,0 +1,117 @@ +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 = "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): + amount = 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", + } + +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) + transactions = strategy.create_transactions(mock_expense, mock_user, []) + + assert len(transactions) == 1 + assert transactions[0]["amount"] == "60.00" + assert transactions[0]["description"] == "Test Expense" + +# Tests for SWBalanceTransactionStrategy +def test_sw_balance_strategy(): + 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 float(transactions[1]["amount"]) == float("50.00") + assert transactions[1]["type"] == "deposit" + 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, mock_get_date): + from main import processExpense, Config + + # Mock configuration + mock_config = Mock(spec=Config) + 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": "110.00", "description": "Test Expense"}, + {"amount": "50.00", "description": "Balance transfer for: Test Expense"} + ] + 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().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": "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') +def test_get_transaction_strategy(mock_request): + mock_request.return_value.json.return_value = {'data': []} + from main import get_transaction_strategy, 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 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