models.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals
  3. import datetime
  4. import logging
  5. import uuid
  6. import re
  7. from decimal import Decimal
  8. from dateutil.relativedelta import relativedelta
  9. from django.conf import settings
  10. from django.db import models, transaction
  11. from django.utils import timezone
  12. from django.utils.encoding import python_2_unicode_compatible
  13. from django.dispatch import receiver
  14. from django.db.models.signals import post_save, post_delete
  15. from django.core.exceptions import ValidationError
  16. from django.core.urlresolvers import reverse
  17. from coin.offers.models import OfferSubscription
  18. from coin.members.models import Member
  19. from coin.html2pdf import render_as_pdf
  20. from coin.utils import private_files_storage, start_of_month, end_of_month, \
  21. postgresql_regexp, send_templated_email, \
  22. disable_for_loaddata
  23. from coin.isp_database.context_processors import branding
  24. from coin.isp_database.models import ISPInfo
  25. accounting_log = logging.getLogger("coin.billing")
  26. def invoice_pdf_filename(instance, filename):
  27. """Nom et chemin du fichier pdf à stocker pour les factures"""
  28. member_id = instance.member.id if instance.member else 0
  29. return 'invoices/%d_%s_%s.pdf' % (member_id,
  30. instance.number,
  31. uuid.uuid4())
  32. @python_2_unicode_compatible
  33. class InvoiceNumber:
  34. """ Logic and validation of invoice numbers
  35. Defines invoice numbers serie in a way that is legal in france.
  36. https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
  37. Our format is YYYY-MM-XXXXXX
  38. - YYYY the year of the bill
  39. - MM month of the bill
  40. - XXXXXX a per-month sequence
  41. """
  42. RE_INVOICE_NUMBER = re.compile(
  43. r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
  44. def __init__(self, date, index):
  45. self.date = date
  46. self.index = index
  47. def get_next(self):
  48. return InvoiceNumber(self.date, self.index + 1)
  49. def __str__(self):
  50. return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
  51. @classmethod
  52. def parse(cls, string):
  53. m = cls.RE_INVOICE_NUMBER.match(string)
  54. if not m:
  55. raise ValueError('Not a valid invoice number: "{}"'.format(string))
  56. return cls(
  57. datetime.date(
  58. year=int(m.group('year')),
  59. month=int(m.group('month')),
  60. day=1),
  61. int(m.group('index')))
  62. @staticmethod
  63. def time_sequence_filter(date, field_name='date'):
  64. """ Build queryset filter to be used to get the invoices from the
  65. numbering sequence of a given date.
  66. :param field_name: the invoice field name to filter on.
  67. :type date: datetime
  68. :rtype: dict
  69. """
  70. return {'{}__month'.format(field_name): date.month}
  71. class InvoiceQuerySet(models.QuerySet):
  72. def get_next_invoice_number(self, date):
  73. last_invoice_number_str = self._get_last_invoice_number(date)
  74. if last_invoice_number_str is None:
  75. # It's the first bill of the month
  76. invoice_number = InvoiceNumber(date, 1)
  77. else:
  78. invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
  79. return str(invoice_number)
  80. def _get_last_invoice_number(self, date):
  81. same_seq_filter = InvoiceNumber.time_sequence_filter(date)
  82. return self.filter(**same_seq_filter).with_valid_number().aggregate(
  83. models.Max('number'))['number__max']
  84. def with_valid_number(self):
  85. """ Excludes previous numbering schemes or draft invoices
  86. """
  87. return self.filter(number__regex=postgresql_regexp(
  88. InvoiceNumber.RE_INVOICE_NUMBER))
  89. class Invoice(models.Model):
  90. INVOICES_STATUS_CHOICES = (
  91. ('open', 'A payer'),
  92. ('closed', 'Reglée'),
  93. ('trouble', 'Litige')
  94. )
  95. validated = models.BooleanField(default=False, verbose_name='validée',
  96. help_text='Once validated, a PDF is generated'
  97. ' and the invoice cannot be modified')
  98. number = models.CharField(max_length=25,
  99. unique=True,
  100. verbose_name='numéro')
  101. status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
  102. default='open',
  103. verbose_name='statut')
  104. date = models.DateField(
  105. default=datetime.date.today, null=True, verbose_name='date',
  106. help_text='Cette date sera définie à la date de validation dans la facture finale')
  107. date_due = models.DateField(
  108. default=end_of_month,
  109. null=True,
  110. verbose_name="date d'échéance de paiement")
  111. member = models.ForeignKey(Member, null=True, blank=True, default=None,
  112. related_name='invoices',
  113. verbose_name='membre',
  114. on_delete=models.SET_NULL)
  115. pdf = models.FileField(storage=private_files_storage,
  116. upload_to=invoice_pdf_filename,
  117. null=True, blank=True,
  118. verbose_name='PDF')
  119. date_last_reminder_email = models.DateTimeField(null=True, blank=True,
  120. verbose_name="Date du dernier email de relance envoyé")
  121. def save(self, *args, **kwargs):
  122. # First save to get a PK
  123. super(Invoice, self).save(*args, **kwargs)
  124. # Then use that pk to build draft invoice number
  125. if not self.validated and self.pk and not self.number:
  126. self.number = 'DRAFT-{}'.format(self.pk)
  127. self.save()
  128. def amount(self):
  129. """
  130. Calcul le montant de la facture
  131. en fonction des éléments de détails
  132. """
  133. total = Decimal('0.0')
  134. for detail in self.details.all():
  135. total += detail.total()
  136. return total.quantize(Decimal('0.01'))
  137. amount.short_description = 'Montant'
  138. def amount_paid(self):
  139. """
  140. Calcul le montant déjà payé à partir des allocations de paiements
  141. """
  142. return sum([a.amount for a in self.allocations.all()])
  143. amount_paid.short_description = 'Montant déjà payé'
  144. def amount_remaining_to_pay(self):
  145. """
  146. Calcul le montant restant à payer
  147. """
  148. return self.amount() - self.amount_paid()
  149. amount_remaining_to_pay.short_description = 'Reste à payer'
  150. def has_owner(self, username):
  151. """
  152. Check if passed username (ex gmajax) is owner of the invoice
  153. """
  154. return (self.member and self.member.username == username)
  155. def generate_pdf(self):
  156. """
  157. Make and store a pdf file for the invoice
  158. """
  159. context = {"invoice": self}
  160. context.update(branding(None))
  161. pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
  162. self.pdf.save('%s.pdf' % self.number, pdf_file)
  163. @transaction.atomic
  164. def validate(self):
  165. """
  166. Switch invoice to validate mode. This set to False the draft field
  167. and generate the pdf
  168. """
  169. self.date = datetime.date.today()
  170. old_number = self.number
  171. self.number = Invoice.objects.get_next_invoice_number(self.date)
  172. self.validated = True
  173. self.save()
  174. self.generate_pdf()
  175. accounting_log.info("Draft invoice %s validated as invoice %s. "
  176. "(Total amount : %f ; Member : %s)"
  177. % (old_number, self.number, self.amount(), self.member))
  178. assert self.pdf_exists()
  179. if self.member is not None:
  180. update_accounting_for_member(self.member)
  181. def pdf_exists(self):
  182. return (self.validated
  183. and bool(self.pdf)
  184. and private_files_storage.exists(self.pdf.name))
  185. def get_absolute_url(self):
  186. return reverse('billing:invoice', args=[self.number])
  187. def __unicode__(self):
  188. return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
  189. def reminder_needed(self):
  190. # If there's no member, there's nobody to be reminded
  191. if self.member is None:
  192. return False
  193. # If bill is close or not validated yet, nope
  194. if self.status != 'open' or not self.validated:
  195. return False
  196. # If bill is not at least one month old, nope
  197. if self.date_due >= timezone.now()+relativedelta(weeks=-4):
  198. return False
  199. # If a reminder has been recently sent, nope
  200. if (self.date_last_reminder_email
  201. and (self.date_last_reminder_email
  202. >= timezone.now() + relativedelta(weeks=-3))):
  203. return False
  204. return True
  205. def send_reminder(self, auto=False):
  206. """ Envoie un courrier pour rappeler à un abonné qu'une facture est
  207. en attente de paiement
  208. :param bill: id of the bill to remind
  209. :param auto: is it an auto email? (changes slightly template content)
  210. """
  211. if not self.reminder_needed():
  212. return False
  213. accounting_log.info("Sending reminder email to %s to pay invoice %s"
  214. % (str(self.member), str(self.number)))
  215. isp_info = ISPInfo.objects.first()
  216. kwargs = {}
  217. # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
  218. if isp_info and isp_info.administrative_email:
  219. kwargs['from_email'] = isp_info.administrative_email
  220. # Si le dernier courriel de relance a été envoyé il y a moins de trois
  221. # semaines, n'envoi pas un nouveau courriel
  222. send_templated_email(
  223. to=self.member.email,
  224. subject_template='billing/emails/reminder_for_unpaid_bill.txt',
  225. body_template='billing/emails/reminder_for_unpaid_bill.html',
  226. context={'member': self.member, 'branding': isp_info,
  227. 'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
  228. 'today': datetime.date.today,
  229. 'auto_sent': auto},
  230. **kwargs)
  231. # Sauvegarde en base la date du dernier envoi de mail de relance
  232. self.date_last_reminder_email = timezone.now()
  233. self.save()
  234. return True
  235. class Meta:
  236. verbose_name = 'facture'
  237. objects = InvoiceQuerySet().as_manager()
  238. class InvoiceDetail(models.Model):
  239. label = models.CharField(max_length=100)
  240. amount = models.DecimalField(max_digits=5, decimal_places=2,
  241. verbose_name='montant')
  242. quantity = models.DecimalField(null=True, verbose_name='quantité',
  243. default=1.0, decimal_places=2, max_digits=4)
  244. tax = models.DecimalField(null=True, default=0.0, decimal_places=2,
  245. max_digits=4, verbose_name='TVA',
  246. help_text='en %')
  247. invoice = models.ForeignKey(Invoice, verbose_name='facture',
  248. related_name='details')
  249. offersubscription = models.ForeignKey(OfferSubscription, null=True,
  250. blank=True, default=None,
  251. verbose_name='abonnement')
  252. period_from = models.DateField(
  253. default=start_of_month,
  254. null=True,
  255. blank=True,
  256. verbose_name='début de période',
  257. help_text='Date de début de période sur laquelle est facturé cet item')
  258. period_to = models.DateField(
  259. default=end_of_month,
  260. null=True,
  261. blank=True,
  262. verbose_name='fin de période',
  263. help_text='Date de fin de période sur laquelle est facturé cet item')
  264. def __unicode__(self):
  265. return self.label
  266. def total(self):
  267. """Calcul le total"""
  268. return (self.amount * (self.tax / Decimal('100.0') +
  269. Decimal('1.0')) *
  270. self.quantity).quantize(Decimal('0.01'))
  271. class Meta:
  272. verbose_name = 'détail de facture'
  273. class Payment(models.Model):
  274. PAYMENT_MEAN_CHOICES = (
  275. ('cash', 'Espèces'),
  276. ('check', 'Chèque'),
  277. ('transfer', 'Virement'),
  278. ('other', 'Autre')
  279. )
  280. member = models.ForeignKey(Member, null=True, blank=True, default=None,
  281. related_name='payments',
  282. verbose_name='membre',
  283. on_delete=models.SET_NULL)
  284. payment_mean = models.CharField(max_length=100, null=True,
  285. default='transfer',
  286. choices=PAYMENT_MEAN_CHOICES,
  287. verbose_name='moyen de paiement')
  288. amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
  289. verbose_name='montant')
  290. date = models.DateField(default=datetime.date.today)
  291. invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
  292. blank=True, related_name='payments')
  293. label = models.CharField(max_length=500,
  294. null=True, blank=True, default="",
  295. verbose_name='libellé')
  296. def save(self, *args, **kwargs):
  297. # Only if no amount already allocated...
  298. if self.amount_already_allocated() == 0:
  299. # If there's a linked invoice and no member defined
  300. if self.invoice and not self.member:
  301. # Automatically set member to invoice's member
  302. self.member = self.invoice.member
  303. super(Payment, self).save(*args, **kwargs)
  304. def clean(self):
  305. # Only if no amount already alloca ted...
  306. if self.amount_already_allocated() == 0:
  307. # If there's a linked invoice and this payment would pay more than
  308. # the remaining amount needed to pay the invoice...
  309. if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
  310. raise ValidationError("This payment would pay more than the invoice's remaining to pay")
  311. def amount_already_allocated(self):
  312. return sum([ a.amount for a in self.allocations.all() ])
  313. def amount_not_allocated(self):
  314. return self.amount - self.amount_already_allocated()
  315. @transaction.atomic
  316. def allocate_to_invoice(self, invoice):
  317. # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
  318. # ...
  319. amount_can_pay = self.amount_not_allocated()
  320. amount_to_pay = invoice.amount_remaining_to_pay()
  321. amount_to_allocate = min(amount_can_pay, amount_to_pay)
  322. accounting_log.info("Allocating %f from payment %s to invoice %s"
  323. % (float(amount_to_allocate), str(self.date),
  324. invoice.number))
  325. PaymentAllocation.objects.create(invoice=invoice,
  326. payment=self,
  327. amount=amount_to_allocate)
  328. # Close invoice if relevant
  329. if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
  330. accounting_log.info("Invoice %s has been paid and is now closed"
  331. % invoice.number)
  332. invoice.status = "closed"
  333. invoice.save()
  334. self.save()
  335. def __unicode__(self):
  336. if self.member is not None:
  337. return 'Paiment de %0.2f€ le %s par %s' \
  338. % (self.amount, str(self.date), self.member)
  339. else:
  340. return 'Paiment de %0.2f€ le %s' \
  341. % (self.amount, str(self.date))
  342. class Meta:
  343. verbose_name = 'paiement'
  344. # This corresponds to a (possibly partial) allocation of a given payment to
  345. # a given invoice.
  346. # E.g. consider an invoice I with total 15€ and a payment P with 10€.
  347. # There can be for example an allocation of 3.14€ from P to I.
  348. class PaymentAllocation(models.Model):
  349. invoice = models.ForeignKey(Invoice, verbose_name='facture associée',
  350. null=False, blank=False,
  351. related_name='allocations')
  352. payment = models.ForeignKey(Payment, verbose_name='facture associée',
  353. null=False, blank=False,
  354. related_name='allocations')
  355. amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
  356. verbose_name='montant')
  357. def get_active_payment_and_invoices(member):
  358. # Fetch relevant and active payments / invoices
  359. # and sort then by chronological order : olders first, newers last.
  360. this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
  361. this_member_payments = [p for p in member.payments.order_by("date")]
  362. # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
  363. # conflict / trouble invoices)
  364. active_payments = [p for p in this_member_payments if p.amount_not_allocated() > 0]
  365. active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0]
  366. return active_payments, active_invoices
  367. def update_accounting_for_member(member):
  368. """
  369. Met à jour le status des factures, des paiements et le solde du compte
  370. d'un utilisateur
  371. """
  372. accounting_log.info("Updating accounting for member %s ..."
  373. % str(member))
  374. accounting_log.info("Member %s current balance is %f ..."
  375. % (str(member), float(member.balance)))
  376. reconcile_invoices_and_payments(member)
  377. this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
  378. this_member_payments = [p for p in member.payments.order_by("date")]
  379. member.balance = compute_balance(this_member_invoices,
  380. this_member_payments)
  381. member.save()
  382. accounting_log.info("Member %s new balance is %f"
  383. % (str(member), float(member.balance)))
  384. def reconcile_invoices_and_payments(member):
  385. """
  386. Rapproche des factures et des paiements qui sont actifs (paiement non alloué
  387. ou factures non entièrement payées) automatiquement.
  388. """
  389. active_payments, active_invoices = get_active_payment_and_invoices(member)
  390. if active_payments == []:
  391. accounting_log.info("(No active payment for %s. No invoice/payment "
  392. "reconciliation needed.)."
  393. % str(member))
  394. return
  395. elif active_invoices == []:
  396. accounting_log.info("(No active invoice for %s. No invoice/payment "
  397. "reconciliation needed.)."
  398. % str(member))
  399. return
  400. accounting_log.info("Initiating reconciliation between "
  401. "invoice and payments for %s" % str(member))
  402. while active_payments != [] and active_invoices != []:
  403. # Only consider the oldest active payment and the oldest active invoice
  404. p = active_payments[0]
  405. # If this payment is to be allocated for a specific invoice...
  406. if p.invoice:
  407. # Assert that the invoice is still 'active'
  408. assert p.invoice in active_invoices
  409. i = p.invoice
  410. accounting_log.info("Payment is to be allocated specifically to " \
  411. "invoice %s" % str(i.number))
  412. else:
  413. i = active_invoices[0]
  414. # TODO : should add an assert that the ammount not allocated / remaining to
  415. # pay is lower before and after calling the allocate_to_invoice
  416. p.allocate_to_invoice(i)
  417. active_payments, active_invoices = get_active_payment_and_invoices(member)
  418. if active_payments == []:
  419. accounting_log.info("No more active payment. Nothing to reconcile anymore.")
  420. elif active_invoices == []:
  421. accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
  422. return
  423. def compute_balance(invoices, payments):
  424. active_payments = [p for p in payments if p.amount_not_allocated() > 0]
  425. active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
  426. s = 0
  427. s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
  428. s += sum([p.amount_not_allocated() for p in active_payments])
  429. return s
  430. @receiver(post_save, sender=Payment)
  431. @disable_for_loaddata
  432. def payment_changed(sender, instance, created, **kwargs):
  433. if created:
  434. accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
  435. % (instance.pk, instance.date, instance.member,
  436. instance.amount, instance.label.encode('utf-8')))
  437. else:
  438. accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
  439. % (instance.pk, instance.date, instance.member,
  440. instance.amount, instance.label.encode('utf-8'),
  441. instance.amount_already_allocated()))
  442. # If this payment is related to a member, update the accounting for
  443. # this member
  444. if (created or instance.amount_not_allocated() != 0) \
  445. and (instance.member is not None):
  446. update_accounting_for_member(instance.member)
  447. @receiver(post_save, sender=Invoice)
  448. @disable_for_loaddata
  449. def invoice_changed(sender, instance, created, **kwargs):
  450. if created:
  451. accounting_log.info("Creating draft invoice %s (Member: %s)."
  452. % ('DRAFT-{}'.format(instance.pk), instance.member))
  453. else:
  454. if not instance.validated:
  455. accounting_log.info("Updating draft invoice %s (Member: %s)."
  456. % (instance.number, instance.member))
  457. else:
  458. accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
  459. % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
  460. @receiver(post_delete, sender=PaymentAllocation)
  461. def paymentallocation_deleted(sender, instance, **kwargs):
  462. invoice = instance.invoice
  463. # Reopen invoice if relevant
  464. if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
  465. accounting_log.info("Reopening invoice %s ..." % invoice.number)
  466. invoice.status = "open"
  467. invoice.save()
  468. @receiver(post_delete, sender=Payment)
  469. def payment_deleted(sender, instance, **kwargs):
  470. accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
  471. % (instance.pk, instance.date, instance.member,
  472. instance.amount, instance.label.encode('utf-8')))
  473. member = instance.member
  474. this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
  475. this_member_payments = [p for p in member.payments.order_by("date")]
  476. member.balance = compute_balance(this_member_invoices,
  477. this_member_payments)
  478. member.save()