Source code for stoqlib.domain.payment.payment

# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4

##
## Copyright (C) 2005-2014 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/.
##
## Author(s): Stoq Team <stoq-devel@async.com.br>
##

"""Payment management implementations.

This module is centered around payments.

The main payment class is |payment| which is a transfer of money,
to or from a |branch|.

Certain changes to a payment is saved in :class:`PaymentChangeHistory`
"""

# pylint: enable=E1101

import logging

from kiwi.currency import currency
from storm.references import Reference, ReferenceSet

from stoqlib.database.expr import TransactionTimestamp
from stoqlib.database.properties import (DateTimeCol, BoolCol,
                                         PriceCol, UnicodeCol, IdentifierCol,
                                         IdCol, EnumCol)
from stoqlib.domain.account import AccountTransaction
from stoqlib.domain.base import Domain
from stoqlib.domain.event import Event
from stoqlib.exceptions import DatabaseInconsistency, StoqlibError
from stoqlib.lib.dateutils import create_date_interval, localnow, localtoday
from stoqlib.lib.translation import stoqlib_gettext

_ = stoqlib_gettext
log = logging.getLogger(__name__)


[docs]class Payment(Domain): """Payment, a transfer of money between a |branch| and |client| or a |supplier|. Payments between: * a client and a branch are :obj:`.TYPE_IN`, has a |sale| associated. * branch and a supplier are :obj:`.TYPE_OUT`, has a |purchase| associated. Payments are sometimes referred to as *installments*. Sales and purchase orders can be accessed via the :obj:`payment group <.group>` +-------------------------+-------------------------+ | **Status** | **Can be set to** | +-------------------------+-------------------------+ | :obj:`STATUS_PREVIEW` | :obj:`STATUS_PENDING` | +-------------------------+-------------------------+ | :obj:`STATUS_PENDING` | :obj:`STATUS_PAID`, | | | :obj:`STATUS_CANCELLED` | +-------------------------+-------------------------+ | :obj:`STATUS_PAID` | :obj:`STATUS_PENDING`, | | | :obj:`STATUS_CANCELLED` | +-------------------------+-------------------------+ | :obj:`STATUS_CANCELLED` | None | +-------------------------+-------------------------+ .. graphviz:: digraph status { STATUS_PREVIEW -> STATUS_PENDING; STATUS_PENDING -> STATUS_PAID; STATUS_PENDING -> STATUS_CANCELLED; STATUS_PAID -> STATUS_PENDING; STATUS_PAID -> STATUS_CANCELLED; } Simple sale workflow: * Creating a sale, status is set to :obj:`STATUS_PREVIEW` * Confirming the sale, status is set to :obj:`STATUS_PENDING` * Paying the installment, status is set to :obj:`STATUS_PAID` * Cancelling the payment, status is set to :obj:`STATUS_CANCELLED` See also: `schema <http://doc.stoq.com.br/schema/tables/payment.html>`__ """ __storm_table__ = 'payment' #: incoming to the company, accounts receivable, payment from #: a |client| to a |branch| TYPE_IN = u'in' #: outgoing from the company, accounts payable, a payment from #: |branch| to a |supplier| TYPE_OUT = u'out' #: payment group this payment belongs to hasn't been confirmed, # should normally be filtered when showing a payment list STATUS_PREVIEW = u'preview' #: payment group has been confirmed and the payment has not been received STATUS_PENDING = u'pending' #: the payment has been received STATUS_PAID = u'paid' # FIXME: Remove these two #: Unused. STATUS_REVIEWING = u'reviewing' #: Unused. STATUS_CONFIRMED = u'confirmed' #: payment was cancelled, for instance the payments of the group was changed, or #: the group was cancelled. STATUS_CANCELLED = u'cancelled' statuses = {STATUS_PREVIEW: _(u'Preview'), STATUS_PENDING: _(u'To Pay'), STATUS_PAID: _(u'Paid'), STATUS_REVIEWING: _(u'Reviewing'), STATUS_CONFIRMED: _(u'Confirmed'), STATUS_CANCELLED: _(u'Cancelled')} #: type of payment :obj:`.TYPE_IN` or :obj:`.TYPE_OUT` payment_type = EnumCol(allow_none=False, default=TYPE_IN) #: A numeric identifier for this object. This value should be used instead of #: :obj:`Domain.id` when displaying a numerical representation of this object to #: the user, in dialogs, lists, reports and such. identifier = IdentifierCol() #: status, see |payment| for more information. status = EnumCol(allow_none=False, default=STATUS_PREVIEW) #: description payment, usually something like "1/3 Money for Sale 1234" description = UnicodeCol(default=None) # FIXME: use TransactionTimestamp() instead to avoid server/client date # inconsistencies #: when this payment was opened open_date = DateTimeCol(default_factory=localnow) #: when this payment is due due_date = DateTimeCol() #: when this payment was paid paid_date = DateTimeCol(default=None) #: when this payment was cancelled cancel_date = DateTimeCol(default=None) # FIXME: Figure out when and why this differs from value #: base value base_value = PriceCol(default=None) #: value of the payment value = PriceCol() #: the actual amount that was paid, including penalties, interest, discount etc. paid_value = PriceCol(default=None) #: interest of this payment interest = PriceCol(default=0) #: discount, an absolute value with the difference between the #: sales price and :obj:`.value` discount = PriceCol(default=0) #: penalty of the payment penalty = PriceCol(default=0) # FIXME: Figure out what this is used for #: number of the payment payment_number = UnicodeCol(default=None) branch_id = IdCol(allow_none=False) #: |branch| associated with this payment. #: For a :obj:`.TYPE_IN` payment, this is the branch that will receive #: the money. For a :obj:`.TYPE_IN` payment, this is the branch that #: will make the payment branch = Reference(branch_id, 'Branch.id') method_id = IdCol() #: |paymentmethod| for this payment #: payment method = Reference(method_id, 'PaymentMethod.id') group_id = IdCol() #: |paymentgroup| for this payment group = Reference(group_id, 'PaymentGroup.id') category_id = IdCol() #: |paymentcategory| this payment belongs to, can be None category = Reference(category_id, 'PaymentCategory.id') #: list of :class:`comments <stoqlib.domain.payment.comments.PaymentComment>` for #: this payment comments = ReferenceSet('id', 'PaymentComment.payment_id') #: :class:`check data <stoqlib.domain.payment.method.CheckData>` for #: this payment check_data = Reference('id', 'CheckData.payment_id', on_remote=True) #: |accounttransaction| for this payment transaction = Reference('id', 'AccountTransaction.payment_id', on_remote=True) card_data = Reference('id', 'CreditCardData.payment_id', on_remote=True) #: indicates if a bill has been received. They are usually delivered by #: mail before the due date. This is not indicating whether the payment has #: been paid, just that the receiver has notified the payer somehow. bill_received = BoolCol(default=False) attachment_id = IdCol() #: |attachment| for this payment attachment = Reference(attachment_id, 'Attachment.id') def __init__(self, store=None, **kw): if not 'value' in kw: raise TypeError('You must provide a value argument') if not 'base_value' in kw or not kw['base_value']: kw['base_value'] = kw['value'] Domain.__init__(self, store=store, **kw) def _check_status(self, status, operation_name): fmt = 'Invalid status for %s operation: %s' assert self.status == status, ( fmt % (operation_name, self.statuses[self.status])) # # ORMObject hooks # def delete(self): # First call hooks, do this first so the hook # have access to everything it needs self.method.operation.payment_delete(self) # FIXME: BUG 5581 check if it is really safe to remove the payment # when using with synced databases self.store.remove(self) @classmethod
[docs] def create_repeated(cls, store, payment, repeat_type, start_date, end_date): """Create a set of repeated payments. Given a type of interval (*repeat_type*), a start date and an end_date, this creates a list of payments for that interval. Note, this will also update the description of the payment that's passed in. :param store: a store :param payment: the payment to repeat :param repeat_type: the kind of repetition (weekly, monthly etc) :param start_date: the date to start this repetition :param end_date: the date to end this repetition :returns: a list of repeated payments """ dates = create_date_interval(interval_type=repeat_type, start_date=start_date, end_date=end_date) n_dates = dates.count() if n_dates == 1: raise AssertionError description = payment.description payment.description = u'1/%d %s' % (n_dates, description) payment.due_date = dates[0] payments = [] for i, date in enumerate(dates[1:]): p = Payment(open_date=payment.open_date, branch=payment.branch, payment_type=payment.payment_type, status=payment.status, description=u'%d/%d %s' % (i + 2, n_dates, description), value=payment.value, base_value=payment.base_value, due_date=date, method=payment.method, group=payment.group, category=payment.category, store=store) payments.append(p) return payments
# # Properties # @property def comments_number(self): """The number of |paymentcomments| for this payment""" return self.comments.count() @property def bank_account_number(self): """For check payments, the :class:`bank account <BankAccount>` number""" # This is used by test_payment_method, and is a convenience # property, ideally we should move it to payment operation # somehow if self.method.method_name == u'check': data = self.method.operation.get_check_data_by_payment(self) bank_account = data.bank_account if bank_account: return bank_account.bank_number @property def installment_number(self): payments = self.group.get_valid_payments().order_by( Payment.identifier) for i, payment in enumerate(payments): if self == payment: return i + 1 @property def status_str(self): """The :obj:`Payment.status` as a translated string""" if not self.status in self.statuses: raise DatabaseInconsistency('Invalid status for Payment ' 'instance, got %d' % self.status) return self.statuses[self.status]
[docs] def get_days_late(self): """For due payments, the number of days late this payment is :returns: the number of days late """ if self.status == Payment.STATUS_PAID: return 0 days_late = localtoday().date() - self.due_date.date() if days_late.days < 0: return 0 return days_late.days
[docs] def set_pending(self): """Set a :obj:`.STATUS_PREVIEW` payment as :obj:`.STATUS_PENDING`. This also means that this is valid payment and its owner actually can charge it """ self._check_status(self.STATUS_PREVIEW, u'set_pending') self.status = self.STATUS_PENDING
[docs] def set_not_paid(self, change_entry): """Set a :obj:`.STATUS_PAID` payment as :obj:`.STATUS_PENDING`. This requires clearing paid_date and paid_value :param change_entry: a :class:`PaymentChangeHistory` object, that will hold the changes information """ self._check_status(self.STATUS_PAID, u'set_not_paid') if self.transaction: self.transaction.create_reverse() change_entry.last_status = self.STATUS_PAID change_entry.new_status = self.STATUS_PENDING sale = self.group and self.group.sale if sale and sale.can_set_not_paid(): sale.set_not_paid() self.status = self.STATUS_PENDING self.paid_date = None self.paid_value = None
[docs] def pay(self, paid_date=None, paid_value=None, source_account=None, destination_account=None, account_transaction_number=None): """Pay the current payment set its status as :obj:`.STATUS_PAID` If this payment belongs to a sale, and all other payments from the sale are paid then the sale will be set as paid. """ if self.status != Payment.STATUS_PENDING: raise ValueError(_(u"This payment is already paid.")) self._check_status(self.STATUS_PENDING, u'pay') paid_value = paid_value or (self.value - self.discount + self.interest) self.paid_value = paid_value self.paid_date = paid_date or TransactionTimestamp() self.status = self.STATUS_PAID if (self.is_separate_payment() or self.method.operation.create_transaction()): AccountTransaction.create_from_payment( self, code=account_transaction_number, source_account=source_account, destination_account=destination_account) sale = self.group and self.group.sale if sale: sale.create_commission(self) # When paying payments of a sale, check if the other payments are # paid. If they are, this means you can change the sale status to # paid as well. if sale.can_set_paid(): sale.set_paid() if self.value == self.paid_value: msg = _(u"{method} payment with value {value:.2f} was paid").format( method=self.method.method_name, value=self.value) else: msg = _(u"{method} payment with value original value " u"{original_value:.2f} was paid with value " u"{value:.2f}").format(method=self.method.method_name, original_value=self.value, value=self.paid_value) Event.log(self.store, Event.TYPE_PAYMENT, msg.capitalize())
[docs] def cancel(self, change_entry=None): """Cancel the payment, set it's status to :obj:`.STATUS_CANCELLED` """ # TODO Check for till entries here and call cancel_till_entry if # it's possible. Bug 2598 if not self.can_cancel(): raise StoqlibError(_(u"Invalid status for cancel operation, " u"got %s") % self.status_str) if self.transaction: self.transaction.create_reverse() old_status = self.status self.status = self.STATUS_CANCELLED self.cancel_date = TransactionTimestamp() if change_entry is not None: change_entry.last_status = old_status change_entry.new_status = self.status msg = _(u"{method} payment with value {value:.2f} was cancelled").format( method=self.method.method_name, value=self.value) Event.log(self.store, Event.TYPE_PAYMENT, msg.capitalize())
[docs] def change_due_date(self, new_due_date): """Changes the payment due date. :param new_due_date: The new due date for the payment. :rtype: datetime.date """ if self.status in [Payment.STATUS_PAID, Payment.STATUS_CANCELLED]: raise StoqlibError(_(u"Invalid status for change_due_date operation, " u"got %s") % self.status_str) self.due_date = new_due_date
[docs] def update_value(self, new_value): """Update the payment value. """ self.value = new_value
def can_cancel(self): return self.status in (Payment.STATUS_PREVIEW, Payment.STATUS_PENDING, Payment.STATUS_PAID)
[docs] def get_payable_value(self): """Returns the calculated payment value with the daily interest. Note that the payment group daily_interest must be between 0 and 100. :returns: the payable value """ if self.status in [self.STATUS_PREVIEW, self.STATUS_CANCELLED]: return self.value if self.status in [self.STATUS_PAID, self.STATUS_REVIEWING, self.STATUS_CONFIRMED]: return self.paid_value return self.value + self.get_interest()
[docs] def get_penalty(self, date=None): """Calculate the penalty in an absolute value :param date: date of payment :returns: penalty :rtype: :class:`kiwi.currency.currency` """ if date is None: date = localtoday().date() elif date < self.open_date.date(): raise ValueError(_(u"Date can not be less then open date")) elif date > localtoday().date(): raise ValueError(_(u"Date can not be greather then future date")) if not self.method.penalty: return currency(0) # Don't add penalty if we pay in time! if self.due_date.date() >= date: return currency(0) return currency(self.method.penalty / 100 * self.value)
[docs] def get_interest(self, date=None, pay_penalty=True): """Calculate the interest in an absolute value :param date: date of payment :returns: interest :rtype: :class:`kiwi.currency.currency` """ if date is None: date = localtoday().date() elif date < self.open_date.date(): raise ValueError(_(u"Date can not be less then open date")) elif date > localtoday().date(): raise ValueError(_(u"Date can not be greather then future date")) if not self.method.daily_interest: return currency(0) days = (date - self.due_date.date()).days if days <= 0: return currency(0) base_value = self.value + (pay_penalty and self.get_penalty(date=date)) return currency(days * self.method.daily_interest / 100 * base_value)
[docs] def has_commission(self): """Check if this |payment| already has a |commission|""" from stoqlib.domain.commission import Commission return self.store.find(Commission, payment=self).any()
[docs] def is_paid(self): """Check if the payment is paid. :returns: ``True`` if the payment is paid """ return self.status == Payment.STATUS_PAID
[docs] def is_pending(self): """Check if the payment is pending. :returns: ``True`` if the payment is pending """ return self.status == Payment.STATUS_PENDING
[docs] def is_preview(self): """Check if the payment is in preview state :returns: ``True`` if the payment is paid """ return self.status == Payment.STATUS_PREVIEW
[docs] def is_cancelled(self): """Check if the payment was cancelled. :returns: ``True`` if the payment was cancelled """ return self.status == Payment.STATUS_CANCELLED
[docs] def get_paid_date_string(self): """Get a paid date string :returns: the paid date string or PAID DATE if the payment isn't paid """ if self.paid_date: return self.paid_date.date().strftime('%x') return _(u'NOT PAID')
[docs] def get_open_date_string(self): """Get a open date string :returns: the open date string or empty string """ if self.open_date: return self.open_date.date().strftime('%x') return u""
[docs] def is_inpayment(self): """Find out if a payment is :obj:`incoming <.TYPE_IN>` :returns: ``True`` if it's incoming """ return self.payment_type == self.TYPE_IN
[docs] def is_outpayment(self): """Find out if a payment is :obj:`outgoing <.TYPE_OUT>` :returns: ``True`` if it's outgoing """ return self.payment_type == self.TYPE_OUT
[docs] def is_separate_payment(self): """Find out if this payment is created separately from a sale, purchase or renegotiation :returns: ``True`` if it's separate. """ # FIXME: This is a hack, we should rather store a flag # in the database that tells us how the payment was # created. group = self.group if not group: # Should never happen return False if group.sale: return False elif group.purchase: return False elif group._renegotiation: return False return True
[docs] def is_of_method(self, method_name): """Find out if the payment was made with a certain method :returns: ``True`` if it's a payment of that method """ return self.method.method_name == method_name
[docs]class PaymentChangeHistory(Domain): """ A class to hold information about changes to a payment. Only one tuple (last_due_date, new_due_date) or (last_status, new_status) should be non-null at a time. See also: `schema <http://doc.stoq.com.br/schema/tables/payment_change_history.html>`__ """ __storm_table__ = 'payment_change_history' payment_id = IdCol() #: the changed |payment| payment = Reference(payment_id, 'Payment.id') #: the reason of the change change_reason = UnicodeCol(default=None) #: when the changed happened change_date = DateTimeCol(default_factory=localnow) #: the due date that was set before the changed last_due_date = DateTimeCol(default=None) #: the due date that was set after changed new_due_date = DateTimeCol(default=None) #: status before the change last_status = EnumCol(allow_none=False, default=Payment.STATUS_PREVIEW) #: status after change new_status = EnumCol(allow_none=False, default=Payment.STATUS_PREVIEW)