Source code for biweeklybudget.biweeklypayperiod

"""
The latest version of this package is available at:
<http://github.com/jantman/biweeklybudget>

################################################################################
Copyright 2016 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>

    This file is part of biweeklybudget, also known as biweeklybudget.

    biweeklybudget is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    biweeklybudget is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with biweeklybudget.  If not, see <http://www.gnu.org/licenses/>.

The Copyright and Authors attributions contained herein may not be removed or
otherwise altered, except to add the Author attribution of a contributor to
this work. (Additional Terms pursuant to Section 7b of the AGPL v3)
################################################################################
While not legally required, I sincerely request that anyone who finds
bugs please submit them at <https://github.com/jantman/biweeklybudget> or
to me via email, and that you send any contributions or improvements
either as a pull request on GitHub, or to me via email.
################################################################################

AUTHORS:
Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
################################################################################
"""

from datetime import timedelta, datetime, date
from functools import total_ordering
from sqlalchemy import or_, asc
from dateutil import relativedelta
from collections import defaultdict
from decimal import Decimal

from biweeklybudget import settings
from biweeklybudget.models import Transaction, ScheduledTransaction, Budget
from biweeklybudget.utils import dtnow


[docs]@total_ordering class BiweeklyPayPeriod(object): """ This object contains all logic related to working with pay periods, specifically finding a pay period for a given data, and figuring out the start and end dates of pay periods. Sure, the app is called "biweeklybudget" but there's no reason to hard-code logic all over the place that's this simple. """ def __init__(self, start_date, db_session): """ Create a new BiweeklyPayPeriod instance. :param start_date: starting date of the pay period :type start_date: :py:class:`datetime.date` or :py:class:`datetime.datetime` :param db_session: active database session to use for queries :type db_session: sqlalchemy.orm.session.Session """ if isinstance(start_date, datetime): start_date = start_date.date() self._db = db_session self._start_date = start_date self._end_date = start_date + self.period_length self._data_cache = {} self._income_budget_id_list = None @property def period_interval(self): """ Return the interval between BiweeklyPayPeriods as a timedelta. :return: interval between BiweeklyPayPeriods :rtype: datetime.timedelta """ return timedelta(days=14) @property def period_length(self): """ Return the length of a BiweeklyPayPeriod; this is calculated as :py:attr:`~.period_interval` minus one second. :return: length of one BiweeklyPayPeriod :rtype: datetime.timedelta """ return self.period_interval - timedelta(days=1) @property def start_date(self): """ Return the starting date for this pay period. The period is generally considered to start at midnight (00:00) of this date. :return: start date for pay period :rtype: datetime.date """ return self._start_date @property def end_date(self): """ Return the date of the last day in this pay period. The pay period is generally considered to end at the last instant (i.e. 23:59:59) of this date. :return: last date in the pay period :rtype: datetime.date """ return self._end_date @property def is_in_past(self): return self.end_date < dtnow().date() @property def next(self): """ Return the BiweeklyPayPeriod following this one. :return: next BiweeklyPayPeriod after this one :rtype: BiweeklyPayPeriod """ return BiweeklyPayPeriod( (self.start_date + self.period_interval), self._db ) @property def previous(self): """ Return the BiweeklyPayPeriod preceding this one. :return: previous BiweeklyPayPeriod before this one :rtype: BiweeklyPayPeriod """ return BiweeklyPayPeriod( (self.start_date - self.period_interval), self._db ) def __repr__(self): return '<BiweeklyPayPeriod(%s)>' % self._start_date.strftime('%Y-%m-%d') def __eq__(self, other): if not isinstance(other, BiweeklyPayPeriod): return NotImplemented return self.start_date == other.start_date def __lt__(self, other): if not isinstance(other, BiweeklyPayPeriod): return NotImplemented return self.start_date < other.start_date
[docs] @staticmethod def period_for_date(dt, db_session): """ Given a datetime, return the BiweeklyPayPeriod instance describing the pay period containing this date. .. todo:: This is a very naive, poorly-performing implementation. :param dt: datetime or date to find the pay period for :type dt: :py:class:`~datetime.datetime` or :py:class:`~datetime.date` :param db_session: active database session to use for queries :type db_session: sqlalchemy.orm.session.Session :return: BiweeklyPayPeriod containing the specified date :rtype: :py:class:`~.BiweeklyPayPeriod` """ p = BiweeklyPayPeriod(settings.PAY_PERIOD_START_DATE, db_session) if isinstance(dt, datetime): dt = dt.date() if dt < p.start_date: while True: if p.end_date >= dt >= p.start_date: return p p = p.previous if dt > p.end_date: while True: if p.end_date >= dt >= p.start_date: return p p = p.next return p
[docs] def filter_query(self, query, date_prop): """ Filter ``query`` for ``date_prop`` in this pay period. Returns a copy of the query. e.g. to filter an existing query of :py:class:`~.OFXTransaction` for the BiweeklyPayPeriod starting on 2017-01-14: .. code-block:: python q = # some query here p = BiweeklyPayPeriod(date(2017, 1, 14)) q = p.filter_query(q, OFXTransaction.date_posted) :param query: The query to filter :type query: ``sqlalchemy.orm.query.Query`` :param date_prop: the Model's date property, to filter on. :return: the filtered query :rtype: ``sqlalchemy.orm.query.Query`` """ return query.filter( date_prop >= self.start_date, date_prop <= self.end_date )
[docs] def _transactions(self): """ Return a Query for all :py:class:`~.Transaction` for this pay period. :return: Query matching all Transactions for this pay period :rtype: sqlalchemy.orm.query.Query """ return self.filter_query( self._db.query(Transaction), Transaction.date )
[docs] def _scheduled_transactions_date(self): """ Return a Query for all :py:class:`~.ScheduledTransaction` defined by date (schedule_type == "date") for this pay period. :return: Query matching all ScheduledTransactions defined by date, for this pay period. :rtype: sqlalchemy.orm.query.Query """ return self.filter_query( self._db.query(ScheduledTransaction).filter( ScheduledTransaction.is_active.__eq__(True)), ScheduledTransaction.date )
[docs] def _scheduled_transactions_per_period(self): """ Return a Query for all :py:class:`~.ScheduledTransaction` defined by number per period (schedule_type == "per period") for this pay period. :return: Query matching all ScheduledTransactions defined by number per period, for this pay period. :rtype: sqlalchemy.orm.query.Query """ return self._db.query(ScheduledTransaction).filter( ScheduledTransaction.schedule_type.__eq__('per period'), ScheduledTransaction.is_active.__eq__(True) ).order_by( asc(ScheduledTransaction.num_per_period), asc(ScheduledTransaction.amount) )
[docs] def _scheduled_transactions_monthly(self): """ Return a Query for all :py:class:`~.ScheduledTransaction` defined by day of month (schedule_type == "monthly") for this pay period. :return: Query matching all ScheduledTransactions defined by day of month (monthly) for this period. :rtype: sqlalchemy.orm.query.Query """ if self.start_date.day < self.end_date.day: # start and end dates are contiguous, in the same month return self._db.query(ScheduledTransaction).filter( ScheduledTransaction.schedule_type.__eq__('monthly'), ScheduledTransaction.is_active.__eq__(True), ScheduledTransaction.day_of_month <= self.end_date.day, ScheduledTransaction.day_of_month >= self.start_date.day ) # else we span two months return self._db.query(ScheduledTransaction).filter( ScheduledTransaction.schedule_type.__eq__('monthly'), ScheduledTransaction.is_active.__eq__(True), or_( ScheduledTransaction.day_of_month <= self.end_date.day, ScheduledTransaction.day_of_month >= self.start_date.day ) )
@property def transactions_list(self): """ Return an ordered list of dicts, each representing a transaction for this pay period. Dicts have keys and values as described in :py:meth:`~._trans_dict`. :return: ordered list of transaction dicts :rtype: list """ return self._data['all_trans_list'] @property def _income_budget_ids(self): """ Return a list of all :py:class:`~.Budget` IDs for Income budgets. :return: list of income budget IDs :rtype: list """ if self._income_budget_id_list is None: self._income_budget_id_list = [ b.id for b in self._db.query( Budget).filter(Budget.is_income.__eq__(True)).all() ] return self._income_budget_id_list @property def _data(self): """ Return the object-local data cache dict. Build it if not already present. :return: object-local data cache :rtype: dict """ if len(self._data_cache) > 0: return self._data_cache self._data_cache = { 'transactions': self._transactions().all(), 'st_date': self._scheduled_transactions_date().all(), 'st_per_period': self._scheduled_transactions_per_period().all(), 'st_monthly': self._scheduled_transactions_monthly().all() } self._data_cache['all_trans_list'] = self._make_combined_transactions() self._data_cache['budget_sums'] = self._make_budget_sums() self._data_cache['overall_sums'] = self._make_overall_sums() return self._data_cache
[docs] def clear_cache(self): """ Clear the cached transaction, budget and sum data stored in `self._data_cache` and returned by :py:attr:`~._data`. """ self._data_cache = {}
[docs] def _make_combined_transactions(self): """ Combine all Transactions and ScheduledTransactions from ``self._data_cache`` into one ordered list of similar dicts, adding dates to the monthly ScheduledTransactions as appropriate and excluding ScheduledTransactions that have been converted to real Transactions. Store the finished list back into ``self._data_cache``. """ unordered = [] # ScheduledTransaction ID to count of real trans for each st_ids = defaultdict(int) for t in self._data_cache['transactions']: unordered.append(self._trans_dict(t)) if t.scheduled_trans_id is not None: st_ids[t.scheduled_trans_id] += 1 for t in self._data_cache['st_date']: if t.id not in st_ids: unordered.append(self._trans_dict(t)) for t in self._data_cache['st_monthly']: if t.id not in st_ids: unordered.append(self._trans_dict(t)) ordered = [] for t in self._data_cache['st_per_period']: d = self._trans_dict(t) for _ in range(0, (t.num_per_period - st_ids[t.id])): ordered.append(d) for t in sorted(unordered, key=lambda k: k['date']): ordered.append(t) def sortkey(k): d = k.get('date', None) if d is None: d = date.min return d, k['amount'] return sorted(ordered, key=sortkey)
@property def budget_sums(self): """ Return a dict of budget sums; the return value of :py:meth:`~._make_budget_sums`. :return: dict of dicts, transaction sums and amounts per budget :rtype: dict """ return self._data['budget_sums']
[docs] def _make_budget_sums(self): """ Find the sums of all transactions per periodic budget ID ; return a dict where keys are budget IDs and values are per-budget dicts containing: - ``budget_amount`` *(Decimal.decimal)* - the periodic budget :py:attr:`~.Budget.starting_balance`. - ``allocated`` *(Decimal.decimal)* - sum of all :py:class:`~.ScheduledTransaction` and :py:class:`~.Transaction` amounts against the budget this period. For actual transactions, we use the :py:attr:`~.Transaction.budgeted_amount` if present (not None). - ``spent`` *(Decimal.decimal)* - the sum of all actual :py:class:`~.Transaction` amounts against the budget this period. - ``trans_total`` *(Decimal.decimal)* - the sum of spent amounts for Transactions that have them, or allocated amounts for ScheduledTransactions. - ``remaining`` *(Decimal.decimal)* - the remaining amount in the budget. This is ``budget_amount`` minus the greater of ``allocated`` or ``trans_total``. For income budgets, this is always positive. :return: dict of dicts, transaction sums and amounts per budget :rtype: dict """ res = {} for b in self._db.query(Budget).filter( Budget.is_active.__eq__(True), Budget.is_periodic.__eq__(True) ).all(): res[b.id] = { 'budget_amount': b.starting_balance, 'allocated': Decimal('0.0'), 'spent': Decimal('0.0'), 'trans_total': Decimal('0.0'), 'is_income': b.is_income } for t in self.transactions_list: # if a ScheduledTransaction, update some values and then continue if t['type'] == 'ScheduledTransaction': for budg_id, budg_data in t['budgets'].items(): if budg_id not in res: # Issue #161 - inactive budget, but transaction for it # in this period. Add it if it's a periodic budget. b = self._db.query(Budget).get(budg_id) if not b.is_periodic: continue res[b.id] = { 'budget_amount': b.starting_balance, 'allocated': Decimal('0.0'), 'spent': Decimal('0.0'), 'trans_total': Decimal('0.0'), 'is_income': b.is_income } res[budg_id]['allocated'] += budg_data['amount'] res[budg_id]['trans_total'] += budg_data['amount'] continue # NOT a ScheduledTransaction; must be an actual Transaction for budg_id, budg_data in t['budgets'].items(): if budg_id not in res: # Issue #161 - inactive budget, but transaction for it # in this period. Add it if it's a periodic budget. b = self._db.query(Budget).get(budg_id) if not b.is_periodic: continue res[b.id] = { 'budget_amount': b.starting_balance, 'allocated': Decimal('0.0'), 'spent': Decimal('0.0'), 'trans_total': Decimal('0.0'), 'is_income': b.is_income } # update the budget's transactions total and spent amount res[budg_id]['trans_total'] += budg_data['amount'] res[budg_id]['spent'] += budg_data['amount'] if t['budgeted_amount'] is None: res[budg_id]['allocated'] += budg_data['amount'] if t['budgeted_amount'] is not None: # has a budgeted amount. It _shouldn't_ be possible to have a # budgeted_amount without a planned_budget_id, but if that # happens, allocate to the first budget. if t.get('planned_budget_id', None) is None: bid = list(t['budgets'].keys())[0] else: bid = t['planned_budget_id'] if bid not in res: # Issue #161 - inactive budget, but transaction for it # in this period. Add it if it's a periodic budget. b = self._db.query(Budget).get(bid) if not b.is_periodic: continue res[bid] = { 'budget_amount': b.starting_balance, 'allocated': Decimal('0.0'), 'spent': Decimal('0.0'), 'trans_total': Decimal('0.0'), 'is_income': b.is_income } res[bid]['allocated'] += t['budgeted_amount'] for b in res.keys(): if res[b]['trans_total'] > res[b]['allocated']: res[b]['remaining'] = res[ b]['budget_amount'] - res[b]['trans_total'] else: res[b]['remaining'] = res[ b]['budget_amount'] - res[b]['allocated'] if res[b]['is_income']: res[b]['remaining'] = abs(res[b]['remaining']) return res
@property def overall_sums(self): """ Return a dict of overall sums; the return value of :py:meth:`~._make_overall_sums`. :return: dict describing sums for the pay period :rtype: dict """ return self._data['overall_sums']
[docs] def _make_overall_sums(self): """ Return a dict describing the overall sums for this pay period, namely: - ``allocated`` *(Decimal.decimal)* total amount allocated via :py:class:`~.ScheduledTransaction`, :py:class:`~.Transaction` (counting the :py:attr:`~.Transaction.budgeted_amount` for Transactions that have one), or :py:class:`~.Budget` (not counting income budgets). - ``spent`` *(Decimal.decimal)* total amount actually spent via :py:class:`~.Transaction`. - ``income`` *(Decimal.decimal)* total amount of income allocated this pay period. Calculated value (from :py:meth:`~._make_budget_sums` / ``self._data_cache['budget_sums']``) should be negative, but is returned as its positive inverse (absolute value). - ``remaining`` *(Decimal.decimal)* income minus the greater of ``allocated`` or ``spent`` for current or future pay periods, or minus ``spent`` for pay periods ending in the past (:py:attr:`~.is_in_past`) :return: dict describing sums for the pay period :rtype: dict """ budgets_total = Decimal('0.0') res = { 'allocated': Decimal('0.0'), 'spent': Decimal('0.0'), 'income': Decimal('0.0'), 'remaining': Decimal('0.0') } for _, b in self._data_cache['budget_sums'].items(): if b['is_income']: if abs(b['trans_total']) > abs(b['budget_amount']): res['income'] += abs(b['trans_total']) else: res['income'] += abs(b['budget_amount']) continue res['allocated'] += max(b['allocated'], b['budget_amount']) res['spent'] += b['spent'] budgets_total += max(b['trans_total'], b['budget_amount']) if self.is_in_past: res['remaining'] = res['income'] - res['spent'] else: res['remaining'] = res['income'] - budgets_total return res
[docs] def _trans_dict(self, t): """ Given a Transaction or ScheduledTransaction, return a dict of a common format describing the object. The resulting dict will have the following layout: * ``type`` (**str**) "Transaction" or "ScheduledTransaction" * ``id`` (**int**) the id of the object * ``date`` (**date**) the date of the transaction, or None for per-period ScheduledTransactions * ``sched_type`` (**str**) for ScheduledTransactions, the schedule type ("monthly", "date", or "per period") * ``sched_trans_id`` (**int**) for Transactions, the ScheduledTransaction ``id`` that it was created from, or None. * ``description`` (**str**) the transaction description * ``amount`` (**Decimal.decimal**) the transaction amount * ``budgeted_amount`` (**Decimal.decimal**) the budgeted amount. This may be None. * ``account_id`` (**int**) the id of the Account the transaction is against. * ``account_name`` (**str**) the name of the Account the transaction is against. * ``budgets`` (**dict**) dict of information on the Budgets this Transaction is against. Keys are budget IDs (**int**), values are dicts with keys "amount" (**Decimal**) and "name" (**string**). * ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None * ``planned_budget_id`` (**int**) the id of the Budget the transaction was planned against, if any. May be None. * ``planned_budget_name`` (**str**) the name of the Budget the transaction was planned against, if any. May be None. :param t: the object to return a dict for :type t: :py:class:`~.Transaction` or :py:class:`~.ScheduledTransaction` :return: dict describing ``t`` :rtype: dict """ if isinstance(t, Transaction): return self._dict_for_trans(t) return self._dict_for_sched_trans(t)
[docs] def _dict_for_trans(self, t): """ Return a dict describing the Transaction t. Called from :py:meth:`~._trans_dict`. The resulting dict will have the following layout: * ``type`` (**str**) "Transaction" or "ScheduledTransaction" * ``id`` (**int**) the id of the object * ``date`` (**date**) the date of the transaction, or None for per-period ScheduledTransactions * ``sched_type`` (**str**) for ScheduledTransactions, the schedule type ("monthly", "date", or "per period") * ``sched_trans_id`` (**int**) for Transactions, the ScheduledTransaction ``id`` that it was created from, or None. * ``description`` (**str**) the transaction description * ``amount`` (**Decimal.decimal**) the transaction amount * ``budgeted_amount`` (**Decimal.decimal**) the budgeted amount. This may be None. * ``account_id`` (**int**) the id of the Account the transaction is against. * ``account_name`` (**str**) the name of the Account the transaction is against. * ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None * ``planned_budget_id`` (**int**) the id of the Budget the transaction was planned against, if any. May be None. * ``planned_budget_name`` (**str**) the name of the Budget the transaction was planned against, if any. May be None. * ``budgets`` (**dict**) dict of information on the Budgets this Transaction is against. Keys are budget IDs (**int**), values are dicts with keys "amount" (**Decimal**) and "name" (**string**). :param t: transaction to describe :type t: Transaction :return: common-format dict describing ``t`` :rtype: dict """ res = { 'type': 'Transaction', 'id': t.id, 'date': t.date, 'sched_type': None, 'sched_trans_id': t.scheduled_trans_id, 'description': t.description, 'amount': t.actual_amount, 'account_id': t.account_id, 'account_name': t.account.name, 'planned_budget_id': t.planned_budget_id, 'planned_budget_name': None, 'budgets': { bt.budget_id: { 'amount': bt.amount, 'name': bt.budget.name } for bt in t.budget_transactions } } if t.reconcile is None: res['reconcile_id'] = None else: res['reconcile_id'] = t.reconcile.id if t.budgeted_amount is None: res['budgeted_amount'] = None else: res['budgeted_amount'] = t.budgeted_amount if t.planned_budget is not None: res['planned_budget_name'] = t.planned_budget.name return res
[docs] def _dict_for_sched_trans(self, t): """ Return a dict describing the ScheduledTransaction t. Called from :py:meth:`~._trans_dict`. The resulting dict will have the following layout: * ``type`` (**str**) "Transaction" or "ScheduledTransaction" * ``id`` (**int**) the id of the object * ``date`` (**date**) the date of the transaction, or None for per-period ScheduledTransactions * ``sched_type`` (**str**) for ScheduledTransactions, the schedule type ("monthly", "date", or "per period") * ``sched_trans_id`` **None** * ``description`` (**str**) the transaction description * ``amount`` (**Decimal.decimal**) the transaction amount * ``budgeted_amount`` **None** * ``account_id`` (**int**) the id of the Account the transaction is against. * ``account_name`` (**str**) the name of the Account the transaction is against. * ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None * ``budgets`` (**dict**) dict of information on the Budgets this Transaction is against. Keys are budget IDs (**int**), values are dicts with keys "amount" (**Decimal**) and "name" (**string**). :param t: ScheduledTransaction to describe :type t: ScheduledTransaction :return: common-format dict describing ``t`` :rtype: dict """ res = { 'type': 'ScheduledTransaction', 'id': t.id, 'sched_type': t.schedule_type, 'sched_trans_id': None, 'description': t.description, 'amount': t.amount, 'budgeted_amount': None, 'account_id': t.account_id, 'account_name': t.account.name, 'reconcile_id': None, 'budgets': { t.budget_id: { 'name': t.budget.name, 'amount': t.amount } } } if t.schedule_type == 'date': res['date'] = t.date return res if t.schedule_type == 'per period': res['date'] = None return res # else it's a monthly transaction, and we need to figure out the date # for it that falls in this PayPeriod. if self.start_date.day <= t.day_of_month <= self.end_date.day: res['date'] = date( year=self.start_date.year, month=self.start_date.month, day=t.day_of_month ) return res # If we got here, we're in a pay period that spans two months; i.e. # start_date.day > end_date.day. if t.day_of_month >= self.start_date.day: # in the same month as start_date.day res['date'] = date( year=self.start_date.year, month=self.start_date.month, day=t.day_of_month ) return res # else t.day_of_month < self.start_date.day, which means it's actually # ``t.day_of_month`` in the next month... res['date'] = date( year=self.start_date.year, month=self.start_date.month, day=t.day_of_month ) + relativedelta.relativedelta(months=1) return res