Skip to content

Commit

Permalink
Split txn (#25)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Sha-yol authored Dec 14, 2024
1 parent 902951e commit 5b28896
Show file tree
Hide file tree
Showing 6 changed files with 102 additions and 30 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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}...")
Expand Down
9 changes: 8 additions & 1 deletion strategies/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions strategies/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
68 changes: 51 additions & 17 deletions strategies/sw_balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
7 changes: 5 additions & 2 deletions tests/test_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

2 comments on commit 5b28896

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
main.py2306571%102, 106, 113, 117, 121, 136, 146, 158, 184–187, 241–242, 244–245, 247–248, 251–254, 256–257, 259, 262, 264–267, 269–270, 285–287, 292–295, 297–298, 321–322, 330, 332–333, 370, 407–408, 410, 414–417, 459, 463, 466, 468, 470–473, 475–476, 478
TOTAL2306571% 

Tests Skipped Failures Errors Time
20 0 💤 0 ❌ 0 🔥 0.660s ⏱️

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
main.py2306571%102, 106, 113, 117, 121, 136, 146, 158, 184–187, 241–242, 244–245, 247–248, 251–254, 256–257, 259, 262, 264–267, 269–270, 285–287, 292–295, 297–298, 321–322, 330, 332–333, 370, 407–408, 410, 414–417, 459, 463, 466, 468, 470–473, 475–476, 478
TOTAL2306571% 

Tests Skipped Failures Errors Time
20 0 💤 0 ❌ 0 🔥 2.685s ⏱️

Please sign in to comment.