django-orchestra/orchestra/contrib/bills/models.py
Jorge Pastor 53fa5a02eb inicio 2023/07/09
edited 2023/11/24 by pedro
2023-11-26 09:52:02 +00:00

505 lines
18 KiB
Python

import datetime
from dateutil.relativedelta import relativedelta
from django.urls import reverse
from django.core.validators import ValidationError, RegexValidator
from django.db import models
from django.db.models import F, Sum
from django.db.models.functions import Coalesce
from django.template import loader
from django.utils import timezone, translation
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from orchestra.admin.utils import change_url
from orchestra.contrib.accounts.models import Account
from orchestra.contrib.contacts.models import Contact
from orchestra.core import validators
from orchestra.utils.functional import cached
from orchestra.utils.html import html_to_pdf
from . import settings
class BillContact(models.Model):
account = models.OneToOneField('accounts.Account', verbose_name=_("account"),
related_name='billcontact', on_delete=models.CASCADE)
name = models.CharField(_("name"), max_length=256, blank=True,
help_text=_("Account full name will be used when left blank."))
address = models.TextField(_("address"))
city = models.CharField(_("city"), max_length=128,
default=settings.BILLS_CONTACT_DEFAULT_CITY)
zipcode = models.CharField(_("zip code"), max_length=10,
validators=[RegexValidator(r'^[0-9A-Z]{3,10}$', _("Enter a valid zipcode."))])
country = models.CharField(_("country"), max_length=20,
choices=settings.BILLS_CONTACT_COUNTRIES,
default=settings.BILLS_CONTACT_DEFAULT_COUNTRY)
vat = models.CharField(_("VAT number"), max_length=64)
def __str__(self):
return self.name
def get_name(self):
return self.name or self.account.get_full_name()
def clean(self):
self.vat = self.vat.strip()
self.city = self.city.strip()
validators.all_valid({
'vat': (validators.validate_vat, self.vat, self.country),
'zipcode': (validators.validate_zipcode, self.zipcode, self.country)
})
class BillManager(models.Manager):
def get_queryset(self):
queryset = super(BillManager, self).get_queryset()
if self.model != Bill:
bill_type = self.model.get_class_type()
queryset = queryset.filter(type=bill_type)
return queryset
class Bill(models.Model):
OPEN = ''
CREATED = 'CREATED'
PROCESSED = 'PROCESSED'
AMENDED = 'AMENDED'
PAID = 'PAID'
EXECUTED = 'EXECUTED'
BAD_DEBT = 'BAD_DEBT'
INCOMPLETE = 'INCOMPLETE'
PAYMENT_STATES = (
(OPEN, _("Open")),
(CREATED, _("Created")),
(PROCESSED, _("Processed")),
(AMENDED, _("Amended")),
(PAID, _("Paid")),
(INCOMPLETE, _('Incomplete')),
(EXECUTED, _("Executed")),
(BAD_DEBT, _("Bad debt")),
)
BILL = 'BILL'
INVOICE = 'INVOICE'
AMENDMENTINVOICE = 'AMENDMENTINVOICE'
FEE = 'FEE'
AMENDMENTFEE = 'AMENDMENTFEE'
PROFORMA = 'PROFORMA'
ABONOINVOICE = 'ABONOINVOICE'
TYPES = (
(INVOICE, _("Invoice")),
(AMENDMENTINVOICE, _("Amendment invoice")),
(FEE, _("Fee")),
(AMENDMENTFEE, _("Amendment Fee")),
(ABONOINVOICE, _("Abono Invoice")),
(PROFORMA, _("Pro forma")),
)
AMEND_MAP = {
INVOICE: AMENDMENTINVOICE,
FEE: AMENDMENTFEE,
}
number = models.CharField(_("number"), max_length=16, unique=True, blank=True)
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='%(class)s', on_delete=models.CASCADE)
amend_of = models.ForeignKey('self', null=True, blank=True, verbose_name=_("amend of"),
related_name='amends', on_delete=models.SET_NULL)
type = models.CharField(_("type"), max_length=16, choices=TYPES)
created_on = models.DateField(_("created on"), auto_now_add=True)
closed_on = models.DateField(_("closed on"), blank=True, null=True, db_index=True)
is_open = models.BooleanField(_("open"), default=True)
is_sent = models.BooleanField(_("sent"), default=False)
due_on = models.DateField(_("due on"), null=True, blank=True)
updated_on = models.DateField(_("updated on"), auto_now=True)
# total = models.DecimalField(max_digits=12, decimal_places=2, null=True)
comments = models.TextField(_("comments"), blank=True)
html = models.TextField(_("HTML"), blank=True)
objects = BillManager()
class Meta:
get_latest_by = 'id'
def __str__(self):
return self.number
@classmethod
def get_class_type(cls):
if cls is models.DEFERRED:
cls = cls.__base__
return cls.__name__.upper()
@cached_property
def total(self):
return self.compute_total()
@cached_property
def seller(self):
return Account.objects.get_main().billcontact
@cached_property
def buyer(self):
return self.account.billcontact
@property
def has_multiple_pages(self):
return self.type != self.FEE
@cached_property
def payment_state(self):
if self.is_open or self.get_type() == self.PROFORMA:
return self.OPEN
secured = 0
pending = 0
created = False
processed = False
executed = False
rejected = False
for transaction in self.transactions.all():
if transaction.state == transaction.SECURED:
secured += transaction.amount
pending += transaction.amount
elif transaction.state == transaction.WAITTING_PROCESSING:
pending += transaction.amount
created = True
elif transaction.state == transaction.WAITTING_EXECUTION:
pending += transaction.amount
processed = True
elif transaction.state == transaction.EXECUTED:
pending += transaction.amount
executed = True
elif transaction.state == transaction.REJECTED:
rejected = True
else:
raise TypeError("Unknown state")
ongoing = bool(secured != 0 or created or processed or executed)
total = self.compute_total()
if total >= 0:
if secured >= total:
return self.PAID
elif ongoing and pending < total:
return self.INCOMPLETE
else:
if secured <= total:
return self.PAID
elif ongoing and pending > total:
return self.INCOMPLETE
if created:
return self.CREATED
elif processed:
return self.PROCESSED
elif executed:
return self.EXECUTED
return self.BAD_DEBT
def clean(self):
if self.amend_of_id:
errors = {}
if self.type not in self.AMEND_MAP.values():
errors['amend_of'] = _("Type %s is not an amendment.") % self.get_type_display()
if self.amend_of.account_id != self.account_id:
errors['account'] = _("Amend of related account doesn't match bill account.")
if self.amend_of.is_open:
errors['amend_of'] = _("Related invoice is in open state.")
if self.amend_of.type in self.AMEND_MAP.values():
errors['amend_of'] = _("Related invoice is an amendment.")
if errors:
raise ValidationError(errors)
def get_payment_state_display(self):
value = self.payment_state
return force_str(dict(self.PAYMENT_STATES).get(value, value))
def get_current_transaction(self):
return self.transactions.exclude_rejected().first()
def get_type(self):
return self.type or self.get_class_type()
@property
def is_amend(self):
return self.type in self.AMEND_MAP.values()
def get_amend_type(self):
amend_type = self.AMEND_MAP.get(self.type)
if amend_type is None:
raise TypeError("%s has no associated amend type." % self.type)
return amend_type
def get_number(self):
cls = type(self)
if cls is models.DEFERRED:
cls = cls.__base__
bill_type = self.get_type()
if bill_type == self.BILL:
raise TypeError('This method can not be used on BILL instances')
bill_type = bill_type.replace('AMENDMENT', 'AMENDMENT_')
prefix = getattr(settings, 'BILLS_%s_NUMBER_PREFIX' % bill_type)
if self.is_open:
prefix = 'O{}'.format(prefix)
year = timezone.now().strftime("%Y")
bills = cls.objects.filter(number__regex=r'^%s%s[0-9]+' % (prefix, year))
last_number = bills.order_by('-number').values_list('number', flat=True).first()
if last_number is None:
last_number = 0
else:
last_number = int(last_number[len(prefix)+4:])
number = last_number + 1
number_length = settings.BILLS_NUMBER_LENGTH
zeros = (number_length - len(str(number))) * '0'
number = zeros + str(number)
return '{prefix}{year}{number}'.format(prefix=prefix, year=year, number=number)
def get_due_date(self, payment=None):
now = timezone.now()
if payment:
return now + payment.get_due_delta()
return now + relativedelta(months=1)
def get_absolute_url(self):
return reverse('admin:bills_bill_view', args=(self.pk,))
def close(self, payment=False):
if not self.is_open:
raise TypeError("Bill not in Open state.")
if payment is False:
payment = self.account.paymentsources.get_default()
if not self.due_on:
self.due_on = self.get_due_date(payment=payment)
total = self.compute_total()
transaction = None
if self.get_type() != self.PROFORMA:
transaction = self.transactions.create(bill=self, source=payment, amount=total)
self.closed_on = timezone.now()
self.is_open = False
self.is_sent = False
self.number = self.get_number()
self.html = self.render(payment=payment)
self.save()
return transaction
def get_billing_contact_emails(self):
return self.account.get_contacts_emails(usages=(Contact.BILLING,))
def send(self):
pdf = self.as_pdf()
self.account.send_email(
template=settings.BILLS_EMAIL_NOTIFICATION_TEMPLATE,
context={
'bill': self,
'settings': settings,
},
email_from=settings.BILLS_SELLER_EMAIL,
usages=(Contact.BILLING,),
attachments=[
('%s.pdf' % self.number, pdf, 'application/pdf')
]
)
self.is_sent = True
self.save(update_fields=['is_sent'])
def render(self, payment=False, language=None):
with translation.override(language or self.account.language):
if payment is False:
payment = self.account.paymentsources.get_default()
context = {
'bill': self,
'lines': self.lines.all().prefetch_related('sublines'),
'seller': self.seller,
'buyer': self.buyer,
'seller_info': {
'phone': settings.BILLS_SELLER_PHONE,
'website': settings.BILLS_SELLER_WEBSITE,
'email': settings.BILLS_SELLER_EMAIL,
'bank_account': settings.BILLS_SELLER_BANK_ACCOUNT,
},
'currency': settings.BILLS_CURRENCY,
'payment': payment and payment.get_bill_context(),
'default_due_date': self.get_due_date(payment=payment),
'now': timezone.now(),
}
template_name = 'BILLS_%s_TEMPLATE' % self.get_type()
template = getattr(settings, template_name, settings.BILLS_DEFAULT_TEMPLATE)
bill_template = loader.get_template(template)
html = bill_template.render(context)
html = html.replace('-pageskip-', '<pdf:nextpage />')
return html
def as_pdf(self):
html = self.html or self.render()
return html_to_pdf(html, pagination=self.has_multiple_pages)
def updated(self):
self.updated_on = timezone.now()
self.save(update_fields=('updated_on',))
def save(self, *args, **kwargs):
if not self.type:
self.type = self.get_type()
if not self.number:
self.number = self.get_number()
super(Bill, self).save(*args, **kwargs)
@cached
def compute_subtotals(self):
subtotals = {}
lines = self.lines.annotate(totals=F('subtotal') + Sum(Coalesce('sublines__total', 0)))
for tax, total in lines.values_list('tax', 'totals'):
try:
subtotals[tax] += total
except KeyError:
subtotals[tax] = total
result = {}
for tax, subtotal in subtotals.items():
result[tax] = [subtotal, round(tax/100*subtotal, 2)]
return result
@cached
def compute_base(self):
bases = self.lines.annotate(
bases=F('subtotal') + Sum(Coalesce('sublines__total', 0))
)
return round(bases.aggregate(Sum('bases'))['bases__sum'] or 0, 2)
@cached
def compute_tax(self):
taxes = self.lines.annotate(
taxes=(F('subtotal') + Coalesce(Sum('sublines__total'), 0)) * (F('tax')/100)
)
return round(taxes.aggregate(Sum('taxes'))['taxes__sum'] or 0, 2)
@cached
def compute_total(self):
if 'lines' in getattr(self, '_prefetched_objects_cache', ()):
total = 0
for line in self.lines.all():
line_total = line.compute_total()
total += line_total * (1+line.tax/100)
return round(total, 2)
else:
totals = self.lines.annotate(
totals=(F('subtotal') + Sum(Coalesce('sublines__total', 0))) * (1+F('tax')/100)
)
return round(totals.aggregate(Sum('totals'))['totals__sum'] or 0, 2)
class Invoice(Bill):
class Meta:
proxy = True
class AmendmentInvoice(Bill):
class Meta:
proxy = True
class AbonoInvoice(Bill):
class Meta:
proxy = True
class Fee(Bill):
class Meta:
proxy = True
class AmendmentFee(Bill):
class Meta:
proxy = True
class ProForma(Bill):
class Meta:
proxy = True
class BillLine(models.Model):
""" Base model for bill item representation """
bill = models.ForeignKey(Bill, verbose_name=_("bill"), related_name='lines', on_delete=models.CASCADE)
description = models.CharField(_("description"), max_length=256)
rate = models.DecimalField(_("rate"), blank=True, null=True, max_digits=12, decimal_places=2)
quantity = models.DecimalField(_("quantity"), blank=True, null=True, max_digits=12,
decimal_places=2)
verbose_quantity = models.CharField(_("Verbose quantity"), max_length=16, blank=True)
subtotal = models.DecimalField(_("subtotal"), max_digits=12, decimal_places=2)
tax = models.DecimalField(_("tax"), max_digits=4, decimal_places=2)
start_on = models.DateField(_("start"))
end_on = models.DateField(_("end"), null=True, blank=True)
order = models.ForeignKey(settings.BILLS_ORDER_MODEL, null=True, blank=True,
related_name='lines', on_delete=models.SET_NULL,
help_text=_("Informative link back to the order"))
order_billed_on = models.DateField(_("order billed"), null=True, blank=True)
order_billed_until = models.DateField(_("order billed until"), null=True, blank=True)
created_on = models.DateField(_("created"), auto_now_add=True)
# Amendment
amended_line = models.ForeignKey('self', verbose_name=_("amended line"),
related_name='amendment_lines', null=True, blank=True, on_delete=models.CASCADE)
class Meta:
get_latest_by = 'id'
def __str__(self):
return "#%i" % self.pk if self.pk else self.description
def get_verbose_quantity(self):
return self.verbose_quantity or self.quantity
def clean(self):
if not self.verbose_quantity:
quantity = str(self.quantity)
# Strip trailing zeros
if quantity.endswith('0'):
self.verbose_quantity = quantity.strip('0').strip('.')
def get_verbose_period(self):
from django.template.defaultfilters import date
date_format = "N 'y"
if self.start_on.day != 1 or (self.end_on and self.end_on.day != 1):
date_format = "N j, 'y"
end = date(self.end_on, date_format)
elif self.end_on:
end = date((self.end_on - datetime.timedelta(days=1)), date_format)
ini = date(self.start_on, date_format).capitalize()
if not self.end_on:
return ini
end = end.capitalize()
if ini == end:
return ini
return "{ini} / {end}".format(ini=ini, end=end)
@cached
def compute_total(self):
total = self.subtotal or 0
if hasattr(self, 'subline_total'):
total += self.subline_total or 0
elif 'sublines' in getattr(self, '_prefetched_objects_cache', ()):
total += sum(subline.total for subline in self.sublines.all())
else:
total += self.sublines.aggregate(sub_total=Sum('total'))['sub_total'] or 0
return round(total, 2)
def get_absolute_url(self):
return change_url(self)
class BillSubline(models.Model):
""" Subline used for describing an item discount """
VOLUME = 'VOLUME'
COMPENSATION = 'COMPENSATION'
OTHER = 'OTHER'
TYPES = (
(VOLUME, _("Volume")),
(COMPENSATION, _("Compensation")),
(OTHER, _("Other")),
)
# TODO: order info for undoing
line = models.ForeignKey(BillLine, verbose_name=_("bill line"), related_name='sublines', on_delete=models.CASCADE)
description = models.CharField(_("description"), max_length=256)
total = models.DecimalField(max_digits=12, decimal_places=2)
type = models.CharField(_("type"), max_length=16, choices=TYPES, default=OTHER)
def __str__(self):
return "%s %i" % (self.description, self.total)