models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals
  3. import datetime
  4. import random
  5. import uuid
  6. import os
  7. import re
  8. from decimal import Decimal
  9. from django.conf import settings
  10. from django.db import models, transaction
  11. from django.db.models.signals import post_save
  12. from django.dispatch import receiver
  13. from django.utils.encoding import python_2_unicode_compatible
  14. from coin.offers.models import OfferSubscription
  15. from coin.members.models import Member
  16. from coin.html2pdf import render_as_pdf
  17. from coin.utils import private_files_storage, start_of_month, end_of_month, \
  18. disable_for_loaddata, postgresql_regexp
  19. from coin.isp_database.context_processors import branding
  20. def invoice_pdf_filename(instance, filename):
  21. """Nom et chemin du fichier pdf à stocker pour les factures"""
  22. member_id = instance.member.id if instance.member else 0
  23. return 'invoices/%d_%s_%s.pdf' % (member_id,
  24. instance.number,
  25. uuid.uuid4())
  26. @python_2_unicode_compatible
  27. class InvoiceNumber:
  28. """ Logic and validation of invoice numbers
  29. Defines invoice numbers serie in a way that is legal in france.
  30. https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
  31. Our format is YYYY-MM-XXXXXX
  32. - YYYY the year of the bill
  33. - MM month of the bill
  34. - XXXXXX a per-month sequence
  35. """
  36. RE_INVOICE_NUMBER = re.compile(
  37. r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
  38. def __init__(self, date, index):
  39. self.date = date
  40. self.index = index
  41. def get_next(self):
  42. return InvoiceNumber(self.date, self.index + 1)
  43. def __str__(self):
  44. return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
  45. @classmethod
  46. def parse(cls, string):
  47. m = cls.RE_INVOICE_NUMBER.match(string)
  48. if not m:
  49. raise ValueError('Not a valid invoice number: "{}"'.format(string))
  50. return cls(
  51. datetime.date(
  52. year=int(m.group('year')),
  53. month=int(m.group('month')),
  54. day=1),
  55. int(m.group('index')))
  56. @staticmethod
  57. def time_sequence_filter(date, field_name='date'):
  58. """ Build queryset filter to be used to get the invoices from the
  59. numbering sequence of a given date.
  60. :param field_name: the invoice field name to filter on.
  61. :type date: datetime
  62. :rtype: dict
  63. """
  64. return {'{}__month'.format(field_name): date.month}
  65. class InvoiceQuerySet(models.QuerySet):
  66. def get_next_invoice_number(self, date):
  67. last_invoice_number_str = self._get_last_invoice_number(date)
  68. if last_invoice_number_str is None:
  69. # It's the first bill of the month
  70. invoice_number = InvoiceNumber(date, 1)
  71. else:
  72. invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
  73. return str(invoice_number)
  74. def _get_last_invoice_number(self, date):
  75. same_seq_filter = InvoiceNumber.time_sequence_filter(date)
  76. return self.filter(**same_seq_filter).with_valid_number().aggregate(
  77. models.Max('number'))['number__max']
  78. def with_valid_number(self):
  79. """ Excludes previous numbering schemes or draft invoices
  80. """
  81. return self.filter(number__regex=postgresql_regexp(
  82. InvoiceNumber.RE_INVOICE_NUMBER))
  83. class Invoice(models.Model):
  84. INVOICES_STATUS_CHOICES = (
  85. ('open', 'À payer'),
  86. ('closed', 'Réglée'),
  87. ('trouble', 'Litige')
  88. )
  89. validated = models.BooleanField(default=False, verbose_name='validée',
  90. help_text='Once validated, a PDF is generated'
  91. ' and the invoice cannot be modified')
  92. number = models.CharField(max_length=25,
  93. unique=True,
  94. verbose_name='numéro')
  95. status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
  96. default='open',
  97. verbose_name='statut')
  98. date = models.DateField(
  99. default=datetime.date.today, null=True, verbose_name='date',
  100. help_text='Cette date sera définie à la date de validation dans la facture finale')
  101. date_due = models.DateField(
  102. null=True, blank=True,
  103. verbose_name="date d'échéance de paiement",
  104. help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
  105. member = models.ForeignKey(Member, null=True, blank=True, default=None,
  106. related_name='invoices',
  107. verbose_name='membre',
  108. on_delete=models.SET_NULL)
  109. pdf = models.FileField(storage=private_files_storage,
  110. upload_to=invoice_pdf_filename,
  111. null=True, blank=True,
  112. verbose_name='PDF')
  113. def save(self, *args, **kwargs):
  114. # First save to get a PK
  115. super(Invoice, self).save(*args, **kwargs)
  116. # Then use that pk to build draft invoice number
  117. if not self.validated and self.pk and not self.number:
  118. self.number = 'DRAFT-{}'.format(self.pk)
  119. self.save()
  120. def amount(self):
  121. """
  122. Calcul le montant de la facture
  123. en fonction des éléments de détails
  124. """
  125. total = Decimal('0.0')
  126. for detail in self.details.all():
  127. total += detail.total()
  128. return total.quantize(Decimal('0.01'))
  129. amount.short_description = 'Montant'
  130. def amount_before_tax(self):
  131. total = Decimal('0.0')
  132. for detail in self.details.all():
  133. total += detail.amount
  134. return total.quantize(Decimal('0.01'))
  135. amount_before_tax.short_description = 'Montant HT'
  136. def amount_paid(self):
  137. """
  138. Calcul le montant payé de la facture en fonction des éléments
  139. de paiements
  140. """
  141. total = Decimal('0.0')
  142. for payment in self.payments.all():
  143. total += payment.amount
  144. return total.quantize(Decimal('0.01'))
  145. amount_paid.short_description = 'Montant payé'
  146. def amount_remaining_to_pay(self):
  147. """
  148. Calcul le montant restant à payer
  149. """
  150. return self.amount() - self.amount_paid()
  151. amount_remaining_to_pay.short_description = 'Reste à payer'
  152. def has_owner(self, username):
  153. """
  154. Check if passed username (ex gmajax) is owner of the invoice
  155. """
  156. return (self.member and self.member.username == username)
  157. def generate_pdf(self):
  158. """
  159. Make and store a pdf file for the invoice
  160. """
  161. context = {"invoice": self}
  162. context.update(branding(None))
  163. pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
  164. self.pdf.save('%s.pdf' % self.number, pdf_file)
  165. @transaction.atomic
  166. def validate(self):
  167. """
  168. Switch invoice to validate mode. This set to False the draft field
  169. and generate the pdf
  170. """
  171. self.date = datetime.date.today()
  172. if not self.date_due:
  173. self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY)
  174. self.number = Invoice.objects.get_next_invoice_number(self.date)
  175. self.validated = True
  176. self.save()
  177. self.generate_pdf()
  178. assert self.pdf_exists()
  179. def pdf_exists(self):
  180. return (self.validated
  181. and bool(self.pdf)
  182. and private_files_storage.exists(self.pdf.name))
  183. def get_absolute_url(self):
  184. from django.core.urlresolvers import reverse
  185. return reverse('billing:invoice', args=[self.number])
  186. def __unicode__(self):
  187. return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
  188. class Meta:
  189. verbose_name = 'facture'
  190. objects = InvoiceQuerySet().as_manager()
  191. class InvoiceDetail(models.Model):
  192. label = models.CharField(max_length=100)
  193. amount = models.DecimalField(max_digits=5, decimal_places=2,
  194. verbose_name='montant')
  195. quantity = models.DecimalField(null=True, verbose_name='quantité',
  196. default=1.0, decimal_places=2, max_digits=4)
  197. tax = models.DecimalField(null=True, default=0.0, decimal_places=2,
  198. max_digits=4, verbose_name='TVA',
  199. help_text='en %')
  200. invoice = models.ForeignKey(Invoice, verbose_name='facture',
  201. related_name='details')
  202. offersubscription = models.ForeignKey(OfferSubscription, null=True,
  203. blank=True, default=None,
  204. verbose_name='abonnement')
  205. period_from = models.DateField(
  206. default=start_of_month,
  207. null=True,
  208. blank=True,
  209. verbose_name='début de période',
  210. help_text='Date de début de période sur laquelle est facturé cet item')
  211. period_to = models.DateField(
  212. default=end_of_month,
  213. null=True,
  214. blank=True,
  215. verbose_name='fin de période',
  216. help_text='Date de fin de période sur laquelle est facturé cet item')
  217. def __unicode__(self):
  218. return self.label
  219. def total(self):
  220. """Calcul le total"""
  221. return (self.amount * (self.tax / Decimal('100.0') +
  222. Decimal('1.0')) *
  223. self.quantity).quantize(Decimal('0.01'))
  224. class Meta:
  225. verbose_name = 'détail de facture'
  226. class Payment(models.Model):
  227. PAYMENT_MEAN_CHOICES = (
  228. ('cash', 'Espèces'),
  229. ('check', 'Chèque'),
  230. ('transfer', 'Virement'),
  231. ('other', 'Autre')
  232. )
  233. payment_mean = models.CharField(max_length=100, null=True,
  234. default='transfer',
  235. choices=PAYMENT_MEAN_CHOICES,
  236. verbose_name='moyen de paiement')
  237. amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
  238. verbose_name='montant')
  239. date = models.DateField(default=datetime.date.today)
  240. invoice = models.ForeignKey(Invoice, verbose_name='facture',
  241. related_name='payments')
  242. def __unicode__(self):
  243. return 'Paiment de %0.2f€' % self.amount
  244. class Meta:
  245. verbose_name = 'paiement'
  246. @receiver(post_save, sender=Payment)
  247. @disable_for_loaddata
  248. def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
  249. """
  250. Lorsqu'un paiement est enregistré, vérifie si la facture est alors
  251. complétement payée. Dans ce cas elle passe en réglée
  252. """
  253. if (instance.invoice.amount_paid() >= instance.invoice.amount() and
  254. instance.invoice.status == 'open'):
  255. instance.invoice.status = 'closed'
  256. instance.invoice.save()