models.py 23 KB

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