# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2005-2012 Async Open Source <http://www.async.com.br>
## All rights reserved
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU Lesser General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program 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 Lesser General Public License for more details.
##
## You should have received a copy of the GNU Lesser General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., or visit: http://www.gnu.org/.
##
"""
This module contains classes centered around account, banks and transactions
between accounts.
The main class is an :class:`Account` holds a set of :class:`AccountTransaction`.
For accounts that are banks there's a :class:`BankAccount` class for
the bank specific state and for bill generation there's also
:class:`BillOption`.
Finally there's a :class:`AccountTransactionView` that is used by
the financial application to efficiently display a ledger.
"""
# pylint: enable=E1101
import datetime
from kiwi.currency import currency
from storm.expr import And, LeftJoin, Or
from storm.info import ClassAlias
from storm.references import Reference
from zope.interface import implementer
from stoqlib.database.expr import TransactionTimestamp, Date
from stoqlib.database.properties import (DateTimeCol, EnumCol, IdCol,
IntCol, PriceCol, UnicodeCol)
from stoqlib.database.viewable import Viewable
from stoqlib.domain.base import Domain
from stoqlib.domain.interfaces import IDescribable
from stoqlib.domain.station import BranchStation
from stoqlib.exceptions import PaymentError
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
[docs]class BillOption(Domain):
"""List of values for bill (boleto) generation
See also:
`schema <http://doc.stoq.com.br/schema/tables/bill_option.html>`__
"""
__storm_table__ = 'bill_option'
#: option name, such as nosso_numero
option = UnicodeCol()
#: value of the option
value = UnicodeCol()
bank_account_id = IdCol()
#: the |bankaccount| this option belongs to
bank_account = Reference(bank_account_id, 'BankAccount.id')
[docs]class BankAccount(Domain):
"""Information specific to a bank
See also:
`schema <http://doc.stoq.com.br/schema/tables/bank_account.html>`__
"""
__storm_table__ = 'bank_account'
account_id = IdCol()
#: the |account| for this bank account
account = Reference(account_id, 'Account.id')
# FIXME: This is brazil specific, should probably be replaced by a
# bank reference to a separate class with name in addition to
# the bank number
#: an identify for the bank type of this account,
bank_number = IntCol(default=0)
#: an identifier for the bank branch/agency which is responsible
#: for this
bank_branch = UnicodeCol(default=None)
#: an identifier for this bank account
bank_account = UnicodeCol(default=None)
@property
def options(self):
"""Get the bill options for this bank account
:returns: a list of :class:`BillOption`
"""
return self.store.find(BillOption,
bank_account=self)
def add_bill_option(self, name, value):
return BillOption(store=self.store, option=name, value=value,
bank_account_id=self.id)
@implementer(IDescribable)
[docs]class Account(Domain):
"""An account, a collection of |accounttransactions| that may be controlled
by a bank.
See also: `schema <http://doc.stoq.com.br/schema/tables/account.html>`__,
`manual <http://doc.stoq.com.br/manual/account.html>`__
"""
__storm_table__ = 'account'
#: Bank
TYPE_BANK = u'bank'
#: Cash/Till
TYPE_CASH = u'cash'
#: Assets, like investement account
TYPE_ASSET = u'asset'
#: Credit
TYPE_CREDIT = u'credit'
#: Income/Salary
TYPE_INCOME = u'income'
#: Expenses
TYPE_EXPENSE = u'expense'
#: Equity, like unbalanced
TYPE_EQUITY = u'equity'
account_labels = {
TYPE_BANK: (_(u"Deposit"), _(u"Withdrawal")),
TYPE_CASH: (_(u"Receive"), _(u"Spend")),
TYPE_ASSET: (_(u"Increase"), _(u"Decrease")),
TYPE_CREDIT: (_(u"Payment"), _(u"Charge")),
TYPE_INCOME: (_(u"Income"), _(u"Charge"),),
TYPE_EXPENSE: (_(u"Rebate"), _(u"Expense")),
TYPE_EQUITY: (_(u"Increase"), _(u"Decrease")),
}
account_type_descriptions = [
(_(u"Bank"), TYPE_BANK),
(_(u"Cash"), TYPE_CASH),
(_(u"Asset"), TYPE_ASSET),
(_(u"Credit"), TYPE_CREDIT),
(_(u"Income"), TYPE_INCOME),
(_(u"Expense"), TYPE_EXPENSE),
(_(u"Equity"), TYPE_EQUITY),
]
#: name of the account
description = UnicodeCol(default=None)
#: code which identifies the account
code = UnicodeCol(default=None)
#: parent account id, can be None
parent_id = IdCol(default=None)
#: parent account
parent = Reference(parent_id, 'Account.id')
station_id = IdCol(default=None)
#: the |branchstation| tied
#: to this account, mainly for TYPE_CASH accounts
station = Reference(station_id, 'BranchStation.id')
#: kind of account, one of the TYPE_* defines in this class
account_type = EnumCol(allow_none=False, default=TYPE_BANK)
#: |bankaccount| for this account, used by TYPE_BANK accounts
bank = Reference('id', 'BankAccount.account_id', on_remote=True)
#
# IDescribable implementation
#
def get_description(self):
return self.description
#
# Class Methods
#
@classmethod
[docs] def get_by_station(cls, store, station):
"""Fetch the account assoicated with a station
:param store: a store
:param station: a |branchstation|
:returns: the account
"""
if station is None:
raise TypeError("station cannot be None")
if not isinstance(station, BranchStation):
raise TypeError("station must be a BranchStation, not %r" %
(station, ))
return store.find(cls, station=station).one()
@classmethod
[docs] def get_children_for(cls, store, parent):
"""Get a list of child accounts for
:param store:
:param |account| parent: parent account
:returns: the child accounts
:rtype: resultset
"""
return store.find(cls, parent=parent)
@classmethod
[docs] def get_accounts(cls, store):
"""Get a list of all accounts
:param store: a store
:returns all accounts
:rtype: resultset
"""
return store.find(cls)
#
# Properties
#
@property
def long_description(self):
"""Get a long description, including all the parent accounts,
such as Tills:cotovia"""
parts = []
account = self
while account:
if not account in parts:
parts.append(account)
account = account.parent
return u':'.join([a.description for a in reversed(parts)])
@property
def transactions(self):
"""Returns a list of transactions to this account.
:returns: list of |accounttransaction|
"""
return self.store.find(AccountTransaction,
Or(self.id == AccountTransaction.account_id,
self.id == AccountTransaction.source_account_id))
#
# Public API
#
[docs] def get_total_for_interval(self, start, end):
"""Fetch total value for a given interval
:param datetime start: beginning of interval
:param datetime end: of interval
:returns: total value or one
"""
if not isinstance(start, datetime.datetime):
raise TypeError("start must be a datetime.datetime, not %s" % (
type(start), ))
if not isinstance(end, datetime.datetime):
raise TypeError("end must be a datetime.datetime, not %s" % (
type(end), ))
query = And(Date(AccountTransaction.date) >= start,
Date(AccountTransaction.date) <= end,
AccountTransaction.source_account_id != AccountTransaction.account_id)
transactions = self.store.find(AccountTransaction, query)
incoming = transactions.find(AccountTransaction.account_id == self.id)
outgoing = transactions.find(AccountTransaction.source_account_id == self.id)
positive_values = incoming.sum(AccountTransaction.value) or 0
negative_values = outgoing.sum(AccountTransaction.value) or 0
return currency(positive_values - negative_values)
[docs] def can_remove(self):
"""If the account can be removed.
Not all accounts can be removed, some are internal to Stoq
and cannot be removed"""
# Can't remove accounts that are used in a parameter
if (sysparam.compare_object('IMBALANCE_ACCOUNT', self) or
sysparam.compare_object('TILLS_ACCOUNT', self) or
sysparam.compare_object('BANKS_ACCOUNT', self)):
return False
# Can't remove station accounts
if self.station:
return False
# When we remove this Account, all the related AccountTransaction will
# be assigned to the IMBALANCE_ACCOUNT and BankAccount will be removed,
# so we need to skip them here
return super(Account, self).can_remove(
skip=[('account_transaction', 'account_id'),
('account_transaction', 'source_account_id'),
('bank_account', 'account_id')])
[docs] def remove(self, store):
"""Remove the current account. This updates all transactions which
refers to this account and removes them.
:param store: a store
"""
if not self.can_remove():
raise TypeError("Account %r cannot be removed" % (self, ))
imbalance_account_id = sysparam.get_object_id('IMBALANCE_ACCOUNT')
for transaction in store.find(AccountTransaction,
account=self):
transaction.account_id = imbalance_account_id
store.flush()
for transaction in store.find(AccountTransaction,
source_account=self):
transaction.source_account_id = imbalance_account_id
store.flush()
bank = self.bank
if bank:
for option in bank.options:
store.remove(option)
store.remove(bank)
self.store.remove(self)
[docs] def has_child_accounts(self):
"""If this account has child accounts
:returns: True if any other accounts has this account as a parent"""
return not self.store.find(Account, parent=self).is_empty()
[docs] def get_type_label(self, out):
"""Returns the label to show for the increases/decreases
for transactions of this account.
See :obj:`~..account_labels`
:param out: if the transaction is going out
"""
return self.account_labels[self.account_type][int(out)]
[docs] def matches(self, account_id):
"""Check if this account or it's parent account is the same
as another account id.
:param account_id: the account id to compare with
:returns: if the accounts matches.
"""
if self.id == account_id:
return True
if self.parent_id and self.parent_id == account_id:
return True
return False
[docs]class AccountTransaction(Domain):
"""Transaction between two accounts.
A transaction is a transfer of money from the
:obj:`~.source_account` to the
:obj:`~.account`.
It removes a negative amount of money from the source and increases
the account by the same amount.
There's only one value, but depending on the view it's either negative
or positive, it can never be zero though.
A transaction can optionally be tied to a |payment|
See also:
`schema <http://doc.stoq.com.br/schema/tables/account_transaction.html>`__
`manual <http://doc.stoq.com.br/manual/transaction.html>`__
"""
__storm_table__ = 'account_transaction'
# operation_type values
TYPE_IN = u'in'
TYPE_OUT = u'out'
# FIXME: It's way to tricky to calculate the direction and it's
# values for an AccountTransaction due to the fact that
# we're only store one value. We should store two values,
# one for how much the current account should be increased
# with and another one which is how much the other account
# should be increased with. For split transaction we might
# want to store more values, so it might make sense to allow
# N values per transaction.
account_id = IdCol()
#: destination |account|
account = Reference(account_id, 'Account.id')
source_account_id = IdCol()
#: source |account|
source_account = Reference(source_account_id, 'Account.id')
#: short human readable summary of the transaction
description = UnicodeCol()
#: identifier of this transaction within a account
code = UnicodeCol()
#: value transfered
value = PriceCol(default=0)
#: date the transaction was done
date = DateTimeCol()
payment_id = IdCol(default=None)
#: |payment| this transaction relates to, can also be ``None``
payment = Reference(payment_id, 'Payment.id')
#: operation_type represents the type of transaction (debit/credit)
operation_type = EnumCol(allow_none=False, default=TYPE_IN)
class sqlmeta:
lazyUpdate = True
@classmethod
[docs] def get_inverted_operation_type(cls, operation_type):
""" Get the inverted operation_type (IN->OUT / OUT->IN)
:param operation_type: the type of transaction
:returns: the inverted transaction type
"""
if operation_type == cls.TYPE_IN:
return cls.TYPE_OUT
return cls.TYPE_IN
@classmethod
[docs] def create_from_payment(cls, payment, code=None,
source_account=None, destination_account=None):
"""Create a new transaction based on a |payment|.
It's normally used when creating a transaction which represents
a payment, for instance when you receive a bill or a check from
a |client| which will enter a |bankaccount|.
:param payment: the |payment| to create the transaction for.
:param code: the code for the transaction. If not provided,
the payment identifier will be used by default
:param source_account: the source |account| for the transaction.
:param destination_account: the destination |account| for the transaction.
:returns: the transaction
"""
if not payment.is_paid():
raise PaymentError(_("Payment needs to be paid"))
store = payment.store
value = payment.paid_value
if payment.is_outpayment():
operation_type = cls.TYPE_OUT
source = source_account or payment.method.destination_account
destination = (destination_account or
sysparam.get_object(store, 'IMBALANCE_ACCOUNT'))
else:
operation_type = cls.TYPE_IN
source = (source_account or
sysparam.get_object(store, 'IMBALANCE_ACCOUNT'))
destination = (destination_account or
payment.method.destination_account)
code = code if code is not None else unicode(payment.identifier)
return cls(source_account=source,
account=destination,
value=value,
description=payment.description,
code=code,
date=payment.paid_date,
store=store,
payment=payment,
operation_type=operation_type)
[docs] def create_reverse(self):
"""Reverse this transaction, this happens when a payment
is set as not paid.
:returns: the newly created account transaction representing
the reversal
"""
# We're effectively canceling the old transaction here,
# to avoid having more than one transaction referencing the same
# payment we reset the payment to None.
#
# It would be nice to have all of them reference the same payment,
# but it makes it harder to create the reversal.
self.payment = None
new_type = self.get_inverted_operation_type(self.operation_type)
return AccountTransaction(
source_account=self.account,
account=self.source_account,
value=self.value,
description=_(u"Reverted: %s") % (self.description),
code=self.code,
date=TransactionTimestamp(),
store=self.store,
payment=None,
operation_type=new_type)
[docs] def invert_transaction_type(self):
""" Invert source/destination accounts and operation_type
When change a incoming transaction to outgoing or vice-versa. The source and
destination accounts must be inverted. Thus, the outgoing value always
will belong to the source account.
"""
temp_account = self.account
operation_type = self.operation_type
self.account = self.source_account
self.source_account = temp_account
self.operation_type = self.get_inverted_operation_type(operation_type)
[docs] def get_other_account(self, account):
"""Get the other end of a transaction
:param account: an |account|
:returns: the other end
"""
if self.source_account == account:
return self.account
elif self.account == account:
return self.source_account
else:
raise AssertionError
[docs] def set_other_account(self, other, account):
"""Set the other end of a transaction
:param other: an |account| which we do not want to set
:param account: the |account| to set
"""
other = self.store.fetch(other)
if self.source_account == other:
self.account = account
elif self.account == other:
self.source_account = account
else:
raise AssertionError
[docs]class AccountTransactionView(Viewable):
"""AccountTransactionView provides a fast view
of the transactions tied to a specific |account|.
It's mainly used to show a ledger.
"""
Account_Dest = ClassAlias(Account, 'account_dest')
Account_Source = ClassAlias(Account, 'account_source')
transaction = AccountTransaction
id = AccountTransaction.id
code = AccountTransaction.code
description = AccountTransaction.description
value = AccountTransaction.value
date = AccountTransaction.date
operation_type = AccountTransaction.operation_type
dest_account_id = Account_Dest.id
dest_account_description = Account_Dest.description
source_account_id = Account_Source.id
source_account_description = Account_Source.description
tables = [
AccountTransaction,
LeftJoin(Account_Dest,
AccountTransaction.account_id == Account_Dest.id),
LeftJoin(Account_Source,
AccountTransaction.source_account_id == Account_Source.id),
]
@classmethod
[docs] def get_for_account(cls, account, store):
"""Get all transactions for this |account|, see Account.transaction"""
return store.find(cls, Or(account.id == AccountTransaction.account_id,
account.id == AccountTransaction.source_account_id))
[docs] def get_account_description(self, account):
"""Get description of the other |account|, eg.
the one which is transfered to/from.
"""
if self.source_account_id == account.id:
return self.dest_account_description
elif self.dest_account_id == account.id:
return self.source_account_description
else:
raise AssertionError
[docs] def get_value(self, account):
""" Gets the transaction value according to an |account|.
If this |account| is the source, the value returned will be negative.
Representing a outgoing transaction.
"""
# A transaction that was not adjusted, will have the source equals
# to destination account. So get the value based on operation type.
if self.source_account_id == self.dest_account_id:
return self.get_value_by_type()
elif self.source_account_id == account.id:
return -self.value
else:
return self.value
[docs] def get_value_by_type(self):
""" Returns the transaction value, based on operation type.
"""
if self.operation_type == AccountTransaction.TYPE_IN:
return self.value
else:
return -self.value