Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Debt tracker #23

Merged
merged 33 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2cf4664
Add .env options for debt tracking
Sha-yol Oct 14, 2024
9916aeb
Add loaded debt parameters to Config
Sha-yol Oct 14, 2024
29b2771
Create strategies skeleton
Sha-yol Oct 14, 2024
6460f65
Create standard strategy
Sha-yol Oct 14, 2024
aa4f62b
Add splitwise balance strategy
Sha-yol Oct 14, 2024
8b99546
Remove redundant option
Sha-yol Oct 14, 2024
e920fc7
Import and use strategy classes
Sha-yol Oct 14, 2024
e5e9878
Use paid amount instead of owed on flag
Sha-yol Oct 14, 2024
a219c97
Modify processExpense to use correct strategy
Sha-yol Oct 14, 2024
2c30fa1
Actually use paid amount on flag
Sha-yol Oct 14, 2024
72aa11a
Add strategy testing skeleton
Sha-yol Oct 19, 2024
d45b54c
Remove redundant option from main
Sha-yol Oct 20, 2024
1a1d617
Convert str to float for comparison
Sha-yol Oct 20, 2024
9a3959b
minor strategy tests changes
Sha-yol Oct 20, 2024
3419f68
test bugfix
Sha-yol Oct 21, 2024
371db39
Fix processExpense logic
Sha-yol Oct 21, 2024
a81f55d
Remove redundant patch
Sha-yol Oct 21, 2024
2b02d23
minor bugfixes
Sha-yol Oct 21, 2024
c7771d5
Remove use_payed_amount option from processExpense
Sha-yol Oct 23, 2024
61b2d6d
Use applyExpenseAmountToTransaction in SW balance strategy
Sha-yol Oct 23, 2024
3910040
Correctly make a deposit to the balance account
Sha-yol Nov 6, 2024
b76dfac
bug: handle deposit type txns
Sha-yol Nov 6, 2024
525d90c
convert str to float for taking negative
Sha-yol Nov 9, 2024
4fb84d5
handle expenses by others
Sha-yol Nov 9, 2024
7d302d9
unify comment style
Sha-yol Nov 11, 2024
f0730e6
refactor for clarity
Sha-yol Nov 11, 2024
f7e6196
flatten dictionary to comply with signature
Sha-yol Nov 11, 2024
4522c2d
clarify amount application method usage
Sha-yol Nov 11, 2024
bf398ac
minor refactor
Sha-yol Nov 11, 2024
8cd5822
fix test_sw_balance_strategy
Sha-yol Nov 13, 2024
a6aa87b
Make test robust to trailing zeros
Sha-yol Nov 13, 2024
0b4fa01
Disable splitwise balance feature in test
Sha-yol Nov 13, 2024
bc58995
Extend README to describe debt tracking
Sha-yol Nov 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Loading