import calendar
import datetime
from decimal import Decimal
from caching.base import CachingManager, CachingMixin, cached_method
from django.core.urlresolvers import reverse
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey, TreeManager
from entries.models import Transaction
from .managers import AccountManager
[docs]class BaseAccountModel(MPTTModel, CachingMixin):
"""Abstract class storing common attributes of Headers and Accounts.
Subclasses must implement the ``_calculate_full_number`` and
``_get_change_tree`` methods.
"""
# TODO: Move to constants.py
ASSET = 1
LIABILITY = 2
EQUITY = 3
INCOME = 4
COST_OF_SALES = 5
EXPENSE = 6
OTHER_INCOME = 7
OTHER_EXPENSE = 8
TYPE_CHOICES = (
(ASSET, 'Asset'),
(LIABILITY, 'Liability'),
(EQUITY, 'Equity'),
(INCOME, 'Income'),
(COST_OF_SALES, 'Cost of Sales'),
(EXPENSE, 'Expense'),
(OTHER_INCOME, 'Other Income'),
(OTHER_EXPENSE, 'Other Expense')
)
name = models.CharField(max_length=50, unique=True)
type = models.PositiveSmallIntegerField(choices=TYPE_CHOICES, blank=True)
description = models.TextField(blank=True)
slug = models.SlugField(help_text="Unique identifier used in URL naming",
unique=True)
full_number = models.CharField(max_length=7, blank=True, null=True,
editable=False)
class Meta:
abstract = True
ordering = ['name']
class MPTTMeta:
order_insertion_by = ['name']
def flip_balance(self):
if self.type in (self.ASSET, self.EXPENSE, self.COST_OF_SALES,
self.OTHER_EXPENSE):
return True
else:
return False
[docs] def clean(self):
"""Set the ``type`` and calculate the ``full_number``.
The ``type`` attribute will be inherited from the ``parent`` and
the ``full_number`` will be calculated if the object has an ``id``.
"""
if self.parent:
self.type = self.parent.type
if self.id:
self.full_number = self._calculate_full_number()
return super(BaseAccountModel, self).clean()
[docs] def delete(self, *args, **kwargs):
"""Renumber Headers or Accounts when deleted."""
items_to_change = self._get_change_tree()
if self in items_to_change:
items_to_change.remove(self)
super(BaseAccountModel, self).delete(*args, **kwargs)
self._resave_items(items_to_change)
[docs] def save(self, *args, **kwargs):
"""Resave Headers or Accounts if the ``parent`` has changed.
This method first checks to see if the ``parent`` attribute has
changed. If so, it will cause the object and all related objects(the
``change_tree``) to be saved once the pending changes have been saved.
"""
tree_has_changed = (self._has_field_changed("parent") or
self._has_field_changed("name"))
if tree_has_changed and self.id:
db_copy = self.__class__.objects.get(id=self.id)
items_to_change = db_copy._get_change_tree()
else:
items_to_change = []
self.full_clean()
super(BaseAccountModel, self).save(*args, **kwargs)
self.__class__.objects.rebuild()
if tree_has_changed:
items_to_change += self._get_change_tree()
self._resave_items(items_to_change)
[docs] def get_full_number(self):
"""Retrieve the Full Number from the model field."""
if self.full_number is not None:
return self.full_number
else:
try:
self.full_number = self._calculate_full_number()
self.save()
return self.full_number
except ValueError:
return None
get_full_number.short_description = "Number"
def _has_field_changed(self, field):
"""Determine if this instance's field has changed."""
if self.id:
database_copy = self.__class__.objects.get(id=self.id)
has_changed = getattr(database_copy, field) != getattr(self, field)
else:
has_changed = True
return has_changed
def _resave_items(self, items):
"""Save each item."""
if self in items:
items.remove(self)
for item in items:
item.save()
[docs]class Account(BaseAccountModel):
"""Holds information on Accounts."""
balance = models.DecimalField(help_text="The balance is the credit/debit "
"balance, not the value balance.",
max_digits=19, default="0.00",
verbose_name="Current Balance",
decimal_places=4)
reconciled_balance = models.DecimalField(
help_text="The Account's currently reconciled balance.",
max_digits=19, decimal_places=4, default="0.00")
parent = models.ForeignKey(Header)
active = models.BooleanField(default=True)
bank = models.BooleanField(default=False, help_text="Mark as a Bank.")
last_reconciled = models.DateField(null=True, blank=True)
objects = AccountManager()
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('accounts.views.show_account_detail',
args=[str(self.slug)])
def account_number(self):
siblings = self.get_siblings(include_self=True).order_by('name')
if self in siblings:
number = list(siblings).index(self) + 1
else:
siblings = list(siblings) + [self]
number = sorted(siblings, key=lambda x: x.name).index(self) + 1
return number
# TODO: get_value_balance()?
[docs] def get_balance(self):
"""
Returns the value balance for the :class:`Account`.
The :class:`Account` model stores the credit/debit balance in the
:attr:`balance` field. This method will convert the credit/debit
balance to a value balance for :attr:`Account.type` where a debit
increases the :class:`Account's<Account>` value, instead of decreasing
it(the normal action).
The ``Current Year Earnings`` :class:`Account` does not source it's
:attr:`~Account.balance` field but instead uses the Sum of all
:class:`Accounts<Account>` with :attr:`~BaseAccountModel.type` of 4 to
8.
.. seealso::
:meth:`~BaseAccountModel.flip_balance` method for more information
on value balances.
:returns: The Account's current value balance.
:rtype: :class:`decimal.Decimal`
"""
if self.name == "Current Year Earnings":
balance = Account.objects.filter(type__in=range(4, 9)).aggregate(
models.Sum('balance')).get('balance__sum') or Decimal(0)
else:
balance = self.balance
if self.flip_balance():
balance *= -1
return balance
@cached_method
[docs] def get_balance_by_date(self, date):
"""
Calculate the :class:`Account's<Account>` balance at the end of a
specific ``date``.
For the ``Current Year Earnings`` :class:`Account`,
:class:`Transactions<Transaction>` from all :class:`Accounts<Account>`
with :attr:`~BaseAccountModel.type` of 4 to 8 will be used.
:param date: The day whose balance should be returned.
:type date: datetime.date
:returns: The Account's balance at the end of the specified date.
:rtype: :class:`decimal.Decimal`
"""
if self.name == "Current Year Earnings":
transaction_set = Transaction.objects.filter(
account__type__in=range(4, 9))
else:
transaction_set = self.transaction_set
past_transactions = transaction_set.filter(date__lte=date).reverse()
if self.name == "Current Year Earnings":
return (past_transactions.aggregate(
models.Sum('balance_delta'))['balance_delta__sum'] or
Decimal(0))
elif past_transactions:
return past_transactions[0].get_final_account_balance()
else:
transaction_sum = (transaction_set.all().aggregate(
models.Sum('balance_delta'))['balance_delta__sum'] or
Decimal(0))
balance = self.balance - transaction_sum
if self.flip_balance():
balance *= -1
return balance
[docs] def get_balance_change_by_month(self, date):
"""
Calculates the :class:`Accounts<Account>` net change in balance for the
month of the specified ``date``.
For the ``Current Year Earnings`` :class:`Account`,
:class:`Transactions<Transaction>` from all :class:`Accounts<Account>`
with :attr:`~BaseAccountModel.type` of 4 to 8 will be used.
:param date: The month to calculate the net change for.
:type date: datetime.date
:returns: The Account's net balance change for the specified month.
:rtype: :class:`decimal.Decimal`
"""
days_in_month = calendar.monthrange(date.year, date.month)[1]
first_day = datetime.date(date.year, date.month, 1)
last_day = datetime.date(date.year, date.month, days_in_month)
query = models.Q(date__gte=first_day, date__lte=last_day)
if self.name == "Current Year Earnings":
query.add(models.Q(account__type__in=range(4, 9)), models.Q.AND)
else:
query.add(models.Q(account__id=self.id), models.Q.AND)
(_, _, net_change) = Transaction.objects.filter(query).get_totals(
net_change=True)
if self.flip_balance():
net_change *= -1
return net_change
def _calculate_full_number(self):
"""Use parent and sibling positions to generate full account number."""
full_number = (self.parent.get_full_number()[:-3] +
"{0:03d}".format(self.account_number()))
return full_number
def _get_change_tree(self):
"""Get this instance's siblings."""
return list(Account.objects.filter(parent=self.parent))
[docs]class HistoricalAccount(CachingMixin, models.Model):
"""
A model for Archiving Historical Account Data.
It stores an :class:`Account's<Account>` balance (for Assets, Liabilities
and Equities) or net_change (for Incomes and Expenses) for a certain month
in a previous :class:`Fiscal Years`.
Hard data is stored in additon to a link back to the originating
:class:`Account`.
.. note::
This model is automatically generated by the
:func:`~fiscalyears.views.add_fiscal_year` view.
.. note::
This model does not inherit from the :class:`BaseAccountModel`
because it has no ``parent`` attribute and cannot inherit from
:class:`MPTTModel`.
.. attribute:: account
The :class:`Account` this HistoricalAccount was generated for. The
HistoricalAccount will remain even if the :class:`Account` is deleted.
.. attribute:: number
The Account number, formatted as `type-num` must be unique with respect
to date.
.. attribute:: name
The Account's name, must be unique with respect to date.
.. attribute:: type
The Account's type, chosen from :attr:`~BaseAccountModel.TYPE_CHOICES`.
.. attribute:: amount
The end-of-month balance for
:class:`HistoricalAccounts<HistoricalAccount>` with :attr:`type` 1-3,
and the net change for :class:`HistoricalAccounts<HistoricalAccount>`
with :attr:`type` 4-8. This field represents the credit/debit amount
not the value amount. To retrieve the value amount for a
:class:`HistoricalAccount` use the :meth:`get_amount` method.
.. attribute:: date
A :class:`datetime.date` object representing the 1st day of the Month
and Year the archive was created.
"""
account = models.ForeignKey(Account, on_delete=models.SET_NULL, blank=True,
null=True)
number = models.CharField(max_length=7)
name = models.CharField(max_length=50)
type = models.PositiveSmallIntegerField(
choices=BaseAccountModel.TYPE_CHOICES)
amount = models.DecimalField(max_digits=19, decimal_places=4)
date = models.DateField()
objects = CachingManager()
class Meta:
ordering = ['date', 'number']
get_latest_by = ('date', )
unique_together = ('date', 'name')
def __unicode__(self):
return '{0}/{1} - {2}'.format(self.date.year, self.date.month,
self.name)
@cached_method
[docs] def get_absolute_url(self):
"""
The default URL for a HistoricalAccount points to the listing for the
:attr:`date's<date>` ``month`` and ``year``.
"""
return reverse('accounts.views.show_account_history',
kwargs={'month': self.date.month,
'year': self.date.year})
@cached_method
[docs] def get_amount(self):
"""
Calculates the flipped/value ``balance`` or ``net_change`` for Asset,
Cost of Sales, Expense and Other Expense
:class:`HistoricalAccounts<HistoricalAccount>`.
The :attr:`amount` field for
:class:`HistoricalAccounts<HistoricalAccount>` represents the
credit/debit amounts but debits for Asset, Cost of Sales, Expense
and Other Expenses represent a positive value instead of a negative
value. This function returns the value amount of these accounts instead
of the debit/credit amount. E.g., a negative(debit) amount will be
returned as a positive value amount.
If the :class:`HistoricalAccount` is not one of these types, the
:class:`HistoricalAccounts<HistoricalAccount>` normal :attr:`amount`
will be returned.
:returns: The value :attr:`amount` for the :class:`HistoricalAccount`
:rtype: :class:`decimal.Decimal`
"""
if self.flip_balance():
return self.amount * -1
else:
return self.amount
@cached_method
[docs] def flip_balance(self):
"""
Determines whether the :attr:`HistoricalAccount.amount` should be
flipped based on the :attr:`HistoricalAccount.type`.
For example, debits(negative :attr:`HistoricalAccount.amount`) increase
the value of Assets, Expenses, Cost of Sales and Other Expenses, while
decreasing the value of all other
:attr:`Account Types<BaseAccountModel.TYPE_CHOICES>`.
In essence, this method will return ``True`` if the credit/debit amount
needs to be negated(multiplied by -1) to display the value amount, and
``False`` if the credit/debit amount is the displayable value amount.
"""
if self.type in (BaseAccountModel.ASSET, BaseAccountModel.EXPENSE,
BaseAccountModel.COST_OF_SALES,
BaseAccountModel.OTHER_EXPENSE):
return True
else:
return False