Skip to content

Commit

Permalink
Merge pull request #5 from dnbasta/dev
Browse files Browse the repository at this point in the history
removed reconcile and clear functionality
  • Loading branch information
dnbasta authored May 5, 2024
2 parents f00090e + 2b2858d commit 8e02691
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 66 deletions.
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,24 @@ split_budget = YnabSplitBudget(user=user, partner=partner)
### 3. Split transactions
Call the `split()` method of the instance. It will split flagged transactions in the budget into a subtransaction with
the original category and a transfer to the split account. By default, the transfer transactions will show up as
uncleared in the split account. The optional `clear` parameter allows to automatically clear the transactions in
the split account. The function returns the updated transactions after applying the split.
uncleared in the split account.
```py
split_budget.split()
```

### 4. Push new splits to partner split account
Calling the `push()` function will insert new transactions from user split account into split account of partner to keep
both accounts in sync. By default, the function will compare and insert transactions of the last 30 days. Optionally it
both accounts in sync. By default, the function will only consider cleared transactions. This is in general a safer
option as it forces users to manually check and clear transactions in the split account. However, it makes running the
whole functionality automatically cumbersome, hence the function takes an optional `include_uncleared` parameter which,
if set to `True`, makes it also consider uncleared transactions.
Additionally by default, the function will compare and insert transactions of the last 30 days. Optionally it
takes a `since` parameter in the form of `datetime.date` to set a timeframe different from 30 days.

```py
split_budget.push()
# or optionally with considering uncleared transactions as well
split_budget.push(include_uncleared=True)
```
## Advanced Usage
### Check Balances
Expand All @@ -77,12 +82,6 @@ form of `datetime.date` to set a timeframe different from 30 days.
```py
split_budget.delete_orphans()
```
### Reconcile split account
The `reconcile()` function allows to reconcile the split account. It does check if the balances match before reconciling
and will an `BalancesDontMatch` error if they don't.
```py
split_budget.reconcile()
```

### Show Logs
The library logs information about the result of the methods at the 'INFO' level. The logs can be made visible by
Expand Down
44 changes: 37 additions & 7 deletions tests/test_adjusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,36 @@


def test_reconcile_filter():

# Arrange
ra = ReconcileAdjuster(credentials=MagicMock())
# Act
t = ra.filter([PropertyMock(cleared='cleared'), PropertyMock(cleared='uncleared'), PropertyMock(cleared='reconciled')])
# Assert
assert len(t) == 1


@patch('ynabsplitbudget.adjusters.ReconcileAdjuster.categories', new_callable=PropertyMock())
def test_reconcile_adjust(mock_categories):
# Arrange
ma = ReconcileAdjuster(credentials=MagicMock())
mock_category = MagicMock(spec=Category)
# Act
t = ma.adjust(PropertyMock(cleared='cleared', category=mock_category),
PropertyMock(cleared='cleared', category=mock_category))
mock_categories.fetch_by_name.assert_not_called()
# Assert
assert t.cleared == 'reconciled'
assert isinstance(t.category, Category)


@patch('ynabsplitbudget.adjusters.ReconcileAdjuster.categories', new_callable=PropertyMock())
def test_reconcile_adjust_wo_category(mock_categories):
# Arrange
ma = ReconcileAdjuster(credentials=MagicMock())
mock_categories.fetch_by_name.return_value = MagicMock(spec=Category)
t = ma.adjust(PropertyMock(cleared='cleared'), PropertyMock(cleared='cleared'))
# Act
t = ma.adjust(PropertyMock(cleared='cleared', category=None), PropertyMock(cleared='cleared', category=None))
# Assert
assert t.cleared == 'reconciled'
assert isinstance(t.category, Category)

Expand All @@ -40,15 +57,16 @@ def test_split_filter():

@patch('ynabsplitbudget.adjusters.SplitAdjuster.payees', new_callable=PropertyMock())
def test_split_adjust(mock_payees):
# Arrange
sa = SplitAdjuster(credentials=MagicMock(), flag_color='red', transfer_payee_id='transfer_payee_id',
account_id='account_id')
mock_payees.fetch_by_id.return_value = Payee(name='transfer_payee')

# Act
mt = sa.adjust(PropertyMock(category=Category(id='category_id', name='category_name'),
amount=-1000,
payee=Payee(name='payee_name'),
memo='@25% memo'), PropertyMock())

# Assert
assert len(mt.subtransactions) == 2
assert mt.subtransactions[0].amount == -250
assert mt.subtransactions[0].payee.name == 'transfer_payee'
Expand All @@ -70,11 +88,23 @@ def test_clear_adjust(mock_categories):
# Arrange
ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=[])
mock_category = Category(name='category_name', id='category_id')
mock_categories.fetch_by_name.return_value = mock_category

# Act
mt = ca.adjust(PropertyMock(cleared='uncleared'), PropertyMock(cleared='uncleared'))
mt = ca.adjust(PropertyMock(cleared='uncleared', category=mock_category),
PropertyMock(cleared='uncleared', category=mock_category))
# Assert
mock_categories.fetch_by_name.assert_not_called()
assert mt.cleared == 'cleared'
assert mt.category == mock_category


@patch('ynabsplitbudget.adjusters.ClearAdjuster.categories', new_callable=PropertyMock())
def test_clear_adjust_wo_category(mock_categories):
# Arrange
ca = ClearAdjuster(credentials=MagicMock(), split_transaction_ids=[])
mock_category = Category(name='category_name', id='category_id')
mock_categories.fetch_by_name.return_value = mock_category
# Act
mt = ca.adjust(PropertyMock(cleared='uncleared', category=None), PropertyMock(cleared='uncleared', category=None))
# Assert
assert mt.cleared == 'cleared'
assert mt.category == mock_category
28 changes: 26 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,36 @@ def test_fetch_account_passes(mock_client, mock_budget):
assert a.transfer_payee_id == 'sample_transfer_payee_id'


def test_fetch_new_cleared_only(mock_client, mock_transaction_dict):
# Arrange
mock_transaction_uncleared = mock_transaction_dict.copy()
mock_transaction_uncleared['cleared'] = 'uncleared'
mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict,
mock_transaction_uncleared],
'server_knowledge': 100}})
# Act
r = mock_client.fetch_roots(since=date(2024, 1, 1), include_uncleared=False)

# Assert
assert len(r) == 1
t = r[0]
assert isinstance(t, RootTransaction)
assert t.share_id == '6f66e5aa449e868261ce'
assert t.account_id == 'sample_account'
assert t.amount == 1000
assert t.id == 'sample_id'
assert t.payee_name == 'sample_payee'
assert t.memo == 'sample_memo'
assert t.transaction_date == date(2024, 1, 1)


def test_fetch_new(mock_client, mock_transaction_dict):
# Arrange
mock_transaction_dict['cleared'] = 'uncleared'
mock_client.session.get.return_value = mock_response({'data': {'transactions': [mock_transaction_dict],
'server_knowledge': 100}})
# Act
r = mock_client.fetch_roots(since=date(2024, 1, 1))
r = mock_client.fetch_roots(since=date(2024, 1, 1), include_uncleared=True)

# Assert
t = r[0]
Expand All @@ -75,7 +99,7 @@ def test_fetch_new_empty(mock_client, mock_transaction_dict):
mock_client.session.get.return_value = mock_response({'data': {'transactions': [],
'server_knowledge': 100}})
# Act
r = mock_client.fetch_roots(since=date(2024, 1, 1))
r = mock_client.fetch_roots(since=date(2024, 1, 1), include_uncleared=False)

# Assert
assert len(r) == 0
Expand Down
6 changes: 3 additions & 3 deletions tests/test_syncrepository.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def test_fetch_new_to_insert_new(mock_lookup, mock_changed):
mock_changed.return_value = [mock_transaction]
mock_lookup.return_value = [mock_lookup_transaction]
# Act
strepo = SyncRepository(user=MagicMock(), partner=MagicMock())
strepo = SyncRepository(user=MagicMock(), partner=MagicMock(), include_uncleared=True)
t = strepo.fetch_roots_wo_complement(since=date(2024, 1, 1))
# Assert
assert isinstance(t[0], RootTransaction)
Expand All @@ -31,7 +31,7 @@ def test_fetch_new_to_insert_not_new(mock_lookup, mock_changed):
mock_changed.return_value = [mock_transaction]
mock_lookup.return_value = [mock_complement]
# Act
strepo = SyncRepository(user=MagicMock(), partner=MagicMock())
strepo = SyncRepository(user=MagicMock(), partner=MagicMock(), include_uncleared=True)
t = strepo.fetch_roots_wo_complement(since=date(2024, 1, 1))
# Assert
assert len(t) == 0
Expand All @@ -44,7 +44,7 @@ def test_fetch_new_to_insert_empty(mock_lookup, mock_changed):
mock_changed.return_value = []
mock_lookup.return_value = []
# Act
strepo = SyncRepository(user=MagicMock(), partner=MagicMock())
strepo = SyncRepository(user=MagicMock(), partner=MagicMock(), include_uncleared=True)
t = strepo.fetch_roots_wo_complement(since=date(2024, 1, 1))
# Assert
assert len(t) == 0
12 changes: 6 additions & 6 deletions ynabsplitbudget/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ def custom_warn(message, category, filename, lineno, file=None, line=None):
'[-p | --partner] <path/partner.yaml> '
'[-s | --split] '
'[-i | --push] '
'[-iu | --push-uncleared] '
'[-b | --balances]'
'[-d | --delete-orphans]'
'[-r | --reconcile]'
'[--since "YYYY-mm-dd"]')
parser.add_argument("-u", "--user", type=str, required=True,
help="path of config YAML to use for user")
Expand All @@ -36,10 +36,10 @@ def custom_warn(message, category, filename, lineno, file=None, line=None):
help="raise error if balances of the two accounts don't match")
parser.add_argument("-d", "--delete-orphans", action="store_true",
help="deletes orphaned transactions in partner account")
parser.add_argument("-r", "--reconcile", action="store_true",
help="reconciles account if balance matches with partner account")
parser.add_argument("--since", type=str,
help='provide optional date if library should use something else than 30 days default')
parser.add_argument('-iu', "--push-uncleared", type=str,
help='push split transactions to partner account including uncleared transactions')

args = parser.parse_args()

Expand All @@ -60,11 +60,11 @@ def custom_warn(message, category, filename, lineno, file=None, line=None):
ysb.split()
if args.push:
ysb.push(since=since)
elif args.push_uncleared:
ysb.push(since=since, include_uncleared=True)
if args.delete_orphans:
ysb.delete_orphans(since=since)
if args.balances or args.reconcile:
if args.balances:
ysb.raise_on_balances_off()
if args.reconcile:
ysb.reconcile()


8 changes: 5 additions & 3 deletions ynabsplitbudget/adjusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def filter(self, transactions: List[Transaction]) -> List[Transaction]:

def adjust(self, original: Transaction, modifier: Modifier) -> Modifier:
modifier.cleared = 'reconciled'
modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign')
if not modifier.category:
modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign')
return modifier


Expand All @@ -30,7 +31,8 @@ def filter(self, transactions: List[Transaction]) -> List[Transaction]:

def adjust(self, original: Transaction, modifier: Modifier) -> Modifier:
modifier.cleared = 'cleared'
modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign')
if not modifier.category:
modifier.category = self.categories.fetch_by_name('Inflow: Ready to Assign')
return modifier


Expand All @@ -43,7 +45,7 @@ def __init__(self, credentials: Credentials, flag_color: str, transfer_payee_id:
self.account_id = account_id

def filter(self, transactions: List[Transaction]) -> List[Transaction]:
return [t for t in transactions if t.cleared == 'cleared' and t.flag_color == self.flag_color
return [t for t in transactions if t.cleared == 'cleared' and t.approved and t.flag_color == self.flag_color
and not t.subtransactions and not t.account.id == self.account_id]

def adjust(self, original: Transaction, modifier: Modifier) -> Modifier:
Expand Down
7 changes: 4 additions & 3 deletions ynabsplitbudget/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,16 @@ def fetch_account(self, budget_id: str, account_id: str) -> Account:
transfer_payee_id=account['transfer_payee_id'],
currency=budget['currency_format']['iso_code'])

def fetch_roots(self, since: date) -> List[RootTransaction]:
def fetch_roots(self, since: date, include_uncleared: bool) -> List[RootTransaction]:
url = f'{YNAB_BASE_URL}budgets/{self.budget_id}/accounts/{self.account_id}/transactions'
r = self.session.get(url, params={'since_date': datetime.strftime(since, '%Y-%m-%d')})
r.raise_for_status()
transactions_dicts = r.json()['data']['transactions']
transactions_filtered = [t for t in transactions_dicts if not t['cleared'] == 'uncleared'
and t['deleted'] is False
transactions_filtered = [t for t in transactions_dicts if t['deleted'] is False
and (t['import_id'] is None or 's||' not in t['import_id'])
and t['payee_name'] != 'Reconciliation Balance Adjustment']
if not include_uncleared:
transactions_filtered = [t for t in transactions_filtered if not t['cleared'] == 'uncleared']
transactions = [self.transaction_builder.build_root(t_dict=t) for t in transactions_filtered]
return transactions

Expand Down
5 changes: 3 additions & 2 deletions ynabsplitbudget/syncrepository.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@

class SyncRepository:

def __init__(self, user: User, partner: User):
def __init__(self, user: User, partner: User, include_uncleared: bool):
self._user_client = Client(token=user.token, budget_id=user.budget_id, account_id=user.account_id,
user_name=user.name)
self._partner_client = Client(token=partner.token, budget_id=partner.budget_id, account_id=partner.account_id,
user_name=partner.name)
self.cleared_only = include_uncleared

def fetch_roots_wo_complement(self, since: date) -> List[RootTransaction]:
roots = self._user_client.fetch_roots(since=since)
roots = self._user_client.fetch_roots(since=since, include_uncleared=self.cleared_only)
pl = [t for t in self._partner_client.fetch_lookup(since) if isinstance(t, ComplementTransaction)]
roots_wo_complement = [t for t in roots if t.share_id not in [lo.share_id for lo in pl]]
transactions_replaced_payee = self.replace_payee(transactions=roots_wo_complement,
Expand Down
37 changes: 6 additions & 31 deletions ynabsplitbudget/ynabsplitbudget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

from ynabtransactionadjuster import Credentials, Transaction

from ynabsplitbudget.adjusters import ReconcileAdjuster, SplitAdjuster, ClearAdjuster
from ynabsplitbudget.adjusters import SplitAdjuster
from ynabsplitbudget.client import Client
from ynabsplitbudget.models.exception import BalancesDontMatch
from ynabsplitbudget.models.transaction import ComplementTransaction, RootTransaction
from ynabsplitbudget.models.transaction import ComplementTransaction
from ynabsplitbudget.models.user import User
from ynabsplitbudget.syncrepository import SyncRepository

Expand All @@ -24,27 +24,27 @@ def __init__(self, user: User, partner: User):
self.partner = partner
self.logger = self._set_up_logger()

def push(self, since: date = None) -> List[ComplementTransaction]:
def push(self, include_uncleared: bool = False, since: date = None) -> List[ComplementTransaction]:
"""Pushes transactions from user split account to partner split account. By default, considers transactions of
last 30 days.
:param include_uncleared: If set to True, will also consider uncleared transactions
:param since: If set to date, will push transactions from that date onwards instead of default 30 days
:return: List of inserted transactions in partner split account
"""
since = self._substitute_default_since(since)
repo = SyncRepository(user=self.user, partner=self.partner)
repo = SyncRepository(user=self.user, partner=self.partner, include_uncleared=include_uncleared)
transactions = repo.fetch_roots_wo_complement(since=since)

complement_transactions = repo.insert_complements(transactions)
logging.getLogger(__name__).info(f'inserted {len(complement_transactions)} complements into account of '
f'{self.partner.name}')
return complement_transactions

def split(self, clear: bool = False) -> List[Transaction]:
def split(self) -> List[Transaction]:
"""Splits transactions (by default 50%) into subtransaction with original category and transfer subtransaction
to split account
:param clear: If set to true transactions in split account will automatically be set to cleared
:return: list with split transactions
"""
creds = Credentials(token=self.user.token, budget=self.user.budget_id)
Expand All @@ -55,16 +55,6 @@ def split(self, clear: bool = False) -> List[Transaction]:
updated_transactions = s.update(mod_trans)
logging.getLogger(__name__).info(f'split {len(updated_transactions)} transactions for {self.user.name}')

if clear:
transfer_transaction_ids = [st.transfer_transaction_id for t in updated_transactions for st in
t.subtransactions if st.transfer_transaction_id]
creds = Credentials(token=self.user.token, budget=self.user.budget_id,
account=self.user.account_id)
ca = ClearAdjuster(creds, split_transaction_ids=transfer_transaction_ids)
mod_trans_clear = ca.apply()
count_clear = len(s.update(mod_trans_clear))
logging.getLogger(__name__).info(f'cleared {count_clear} transactions for {self.user.name}')

return updated_transactions

def raise_on_balances_off(self):
Expand Down Expand Up @@ -94,21 +84,6 @@ def delete_orphans(self, since: date = None) -> List[ComplementTransaction]:
logging.getLogger(__name__).info(orphaned_complements)
return orphaned_complements

def reconcile(self) -> int:
"""Reconciles cleared transactions in the current account
:returns: count of reconciled transactions
:raises BalancesDontMatch: if cleared amounts in both accounts don't match
"""
self.raise_on_balances_off()
creds = Credentials(token=self.user.token, budget=self.user.budget_id,
account=self.user.account_id)
ra = ReconcileAdjuster(creds)
mod_trans = ra.apply()
updated_trans = ra.update(mod_trans)
logging.getLogger(__name__).info(f'reconciled {len(updated_trans)} transactions for {self.user.name}')
return len(updated_trans)

@staticmethod
def _substitute_default_since(since: Optional[date]) -> date:
if since is None:
Expand Down

0 comments on commit 8e02691

Please sign in to comment.