From 5b28896493f9761ac0fd3dace0fde46cdc36ea9c Mon Sep 17 00:00:00 2001 From: Sha-yol <32091466+Sha-yol@users.noreply.github.com> Date: Sun, 15 Dec 2024 01:09:08 +0200 Subject: [PATCH] Split txn (#25) * extend `addTransaction` signature to handle split transactions. * Add cover transaction to SW balance strategy * minor refactor for readability * Update tests * Update readme with changes * Handle split txns in processExpense * Generalize `create_transactions` signature to handle split transactions. * doc * doc * bugfix * bugfix * bugfix: skip withdrawal transactions if I didn't pay * Remove categorization of SW balance transfers --- README.md | 2 +- main.py | 30 ++++++++++++------ strategies/base.py | 9 +++++- strategies/standard.py | 16 ++++++++++ strategies/sw_balance.py | 68 ++++++++++++++++++++++++++++++---------- tests/test_strategies.py | 7 +++-- 6 files changed, 102 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bc0139c..2521aeb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ When enabled, tracks Splitwise payable and receivable debts in an account define 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 +1. A withdrawal from a real account, recording the real amount of money paid in the expense. The withdrawal is split in two parts - the owed amount and the remainder. This allows, for example, to assign only the owed part to a budget. 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 e316dc8..8949c1c 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ from splitwise.user import ExpenseUser from typing import Generator, TypedDict, Union from functools import wraps +from typing import Union import os import requests @@ -269,25 +270,32 @@ def updateTransaction(newTxn: dict, oldTxnBody: dict) -> None: print(f"Updated Transaction: {newTxn['description']}") -def addTransaction(newTxn: dict) -> None: +def addTransaction(newTxn: Union[dict, list[dict]], group_title=None) -> None: """ Add a transaction to Firefly. - :param newTxn: A dictionary of the transaction body. + + If newTxn is a dictionary, add a single transaction. If newTxn is a list of dictionaries, add a split transaction. + + :param newTxn: A dictionary of the transaction body, or a list of such dictionaries for a split transaction. + :param group_title: The title of the transaction group. If None, use the description of the first transaction. :return: None - :raises: Exception if the transaction addition fails + :raises: Exception if the transaction add fails. """ + + txns: list[dict] = [newTxn] if isinstance(newTxn, dict) else newTxn + group_title = group_title or txns[0]["description"] body = { "error_if_duplicate_hash": True, - "group_title": newTxn["description"], - "transactions": [newTxn] + "group_title": group_title, + "transactions": txns } try: callApi("transactions", method="POST", body=body).json() except Exception as e: print( - f"Transaction {newTxn['description']} errored, body: {body}, e: {e}") + f"Transaction {group_title} errored, body: {body}, e: {e}") raise - print(f"Added Transaction: {newTxn['description']}") + print(f"Added Transaction: {group_title}") def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> None: @@ -302,12 +310,16 @@ def processExpense(past_day: datetime, txns: dict[dict], exp: Expense, *args) -> """ strategy = get_transaction_strategy() - new_txns: list[dict] = strategy.create_transactions(exp, *args) + new_txns: list = 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 isinstance(new_txn, dict): + new_txn["external_url"] = external_url + else: + for split in new_txn: + split["external_url"] = external_url if oldTxnBody := txns.get(external_url): print(f"Updating transaction {idx + 1}...") diff --git a/strategies/base.py b/strategies/base.py index 2f64e8e..e2646e5 100644 --- a/strategies/base.py +++ b/strategies/base.py @@ -4,5 +4,12 @@ class TransactionStrategy(ABC): @abstractmethod - def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list: + """ + Create transactions for the given expense and user's share of the expense. + + :param exp: Expense to create transactions from + :param myshare: ExpenseUser object representing the user's share in the expense + :param data: List of strings containing additional data for the transaction + """ pass \ No newline at end of file diff --git a/strategies/standard.py b/strategies/standard.py index 745cf2a..ba2f05d 100644 --- a/strategies/standard.py +++ b/strategies/standard.py @@ -4,7 +4,23 @@ class StandardTransactionStrategy(TransactionStrategy): def __init__(self, get_expense_transaction_body) -> None: + """ + Initialize the StandardTransactionStrategy with the function to get the transaction body. + + :param get_expense_transaction_body: Function to get the transaction body for the expense. Must take the expense, user's share, and additional data as arguments. + """ + self._get_expense_transaction_body = get_expense_transaction_body def create_transactions(self, exp: Expense, myshare: ExpenseUser, data: list[str]) -> list[dict]: + """ + Create a transaction for the given expense and user's share of the expense. + + Create a single transaction for the expense using the provided function to get the transaction from the expense, user's share, and additional data. + + :param exp: Expense to create transactions from + :param myshare: ExpenseUser object representing the user's share in the expense + :param data: List of strings containing additional data for the transaction + """ + 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 index eeda7aa..4727c75 100644 --- a/strategies/sw_balance.py +++ b/strategies/sw_balance.py @@ -4,31 +4,65 @@ class SWBalanceTransactionStrategy(TransactionStrategy): def __init__(self, get_expense_transaction_body, sw_balance_account, apply_transaction_amount) -> None: + """ + Initialize the SWBalanceTransactionStrategy. + + :param get_expense_transaction_body: Function to get the transaction body for the expense. Must take the expense, user's share, and additional data as arguments. + :param sw_balance_account: Name of the Splitwise balance account for the user. + :param apply_transaction_amount: Function to apply the transaction amount to the transaction body. Must take the transaction body, expense, and amount as arguments. + """ + 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 + """ + Create transactions for the given expense and user's share of the expense. + + Create transactions for the expense using the provided function to get the transaction from the expense, user's share, and additional data. + If the user paid for the expense, create a payment withdrawal transaction and a cover deposit transaction to the Splitwise balance. Split the payment transaction to the owed amount and the cover amount. + If the user owes money for the expense, create a balance transfer withdrawal transaction from the Splitwise balance account. - balance_txn = paid_txn.copy() + :param exp: Expense to create transactions from + :param myshare: ExpenseUser object representing the user's share in the expense + :param data: List of strings containing additional data for the transaction + """ + + txns = {} + owed_txn = self._get_expense_transaction_body(exp, myshare, data) + description = owed_txn['description'] balance = float(myshare.getNetBalance()) + + # Create cover transaction + cover_txn = self._apply_transaction_amount(owed_txn.copy(), exp, balance) + cover_txn.update({ + 'description': f"Cover for: {description}", + 'category_name': '' + }) + + if float(myshare.getPaidShare()) != 0: # I paid; payment txn needed + txns['paid'] = [owed_txn, cover_txn] + if balance != 0: # I owe or am owed; balance txn needed - txns['balance'] = balance_txn + balance_txn = owed_txn.copy() + balance_txn.update({ + 'description': f"Balance transfer for: {description}", + 'type': 'deposit' if balance > 0 else 'withdrawal', + 'category_name': '' + }) + 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) + balance_txn.update({ + 'source_name': self._sw_balance_account + " balancer", + 'destination_name': self._sw_balance_account + }) 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) + balance_txn.update({ + 'source_name': self._sw_balance_account, + 'destination_name': owed_txn['destination_name'] + }) + balance = -balance + balance_txn = self._apply_transaction_amount(balance_txn, exp, balance) + txns['balance'] = balance_txn return list(txns.values()) \ No newline at end of file diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 2b3987d..e9390e9 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -52,8 +52,11 @@ def test_sw_balance_strategy(): 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[0][0]["amount"] == "60.00" + assert transactions[0][0]["description"] == "Test Expense" + assert float(transactions[0][1]["amount"]) == float("50.00") + assert transactions[0][1]["description"] == "Cover for: Test Expense" + assert transactions[0][1]["category_name"] == "" assert float(transactions[1]["amount"]) == float("50.00") assert transactions[1]["type"] == "deposit" assert transactions[1]["destination_name"] == "Splitwise Balance"