Skip to content

Commit

Permalink
Debt tracker (#23)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Sha-yol authored Nov 14, 2024
1 parent 38880fe commit 902951e
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 32 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 49 additions & 18 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down
Empty file added strategies/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions strategies/base.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions strategies/standard.py
Original file line number Diff line number Diff line change
@@ -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)]
34 changes: 34 additions & 0 deletions strategies/sw_balance.py
Original file line number Diff line number Diff line change
@@ -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())
32 changes: 18 additions & 14 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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,
Expand All @@ -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()
Expand Down
117 changes: 117 additions & 0 deletions tests/test_strategies.py
Original file line number Diff line number Diff line change
@@ -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)

2 comments on commit 902951e

@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.py2246172%101, 105, 112, 116, 120, 135, 145, 157, 183–186, 240–241, 243–244, 246–247, 250–253, 255–256, 258, 261, 263–266, 268–269, 279, 284–287, 289–290, 318, 320–321, 358, 395–396, 398, 402–405, 447, 451, 454, 456, 458–461, 463–464, 466
TOTAL2246172% 

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

@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.py2246172%101, 105, 112, 116, 120, 135, 145, 157, 183–186, 240–241, 243–244, 246–247, 250–253, 255–256, 258, 261, 263–266, 268–269, 279, 284–287, 289–290, 318, 320–321, 358, 395–396, 398, 402–405, 447, 451, 454, 456, 458–461, 463–464, 466
TOTAL2246172% 

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

Please sign in to comment.