models.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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.db import models, transaction
  9. from django.utils.encoding import python_2_unicode_compatible
  10. from coin.offers.models import OfferSubscription
  11. from coin.members.models import Member
  12. from coin.html2pdf import render_as_pdf
  13. from coin.utils import private_files_storage, start_of_month, end_of_month, \
  14. postgresql_regexp
  15. from coin.isp_database.context_processors import branding
  16. accounting_log = logging.getLogger("coin.billing")
  17. def invoice_pdf_filename(instance, filename):
  18. """Nom et chemin du fichier pdf à stocker pour les factures"""
  19. member_id = instance.member.id if instance.member else 0
  20. return 'invoices/%d_%s_%s.pdf' % (member_id,
  21. instance.number,
  22. uuid.uuid4())
  23. @python_2_unicode_compatible
  24. class InvoiceNumber:
  25. """ Logic and validation of invoice numbers
  26. Defines invoice numbers serie in a way that is legal in france.
  27. https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
  28. Our format is YYYY-MM-XXXXXX
  29. - YYYY the year of the bill
  30. - MM month of the bill
  31. - XXXXXX a per-month sequence
  32. """
  33. RE_INVOICE_NUMBER = re.compile(
  34. r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
  35. def __init__(self, date, index):
  36. self.date = date
  37. self.index = index
  38. def get_next(self):
  39. return InvoiceNumber(self.date, self.index + 1)
  40. def __str__(self):
  41. return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
  42. @classmethod
  43. def parse(cls, string):
  44. m = cls.RE_INVOICE_NUMBER.match(string)
  45. if not m:
  46. raise ValueError('Not a valid invoice number: "{}"'.format(string))
  47. return cls(
  48. datetime.date(
  49. year=int(m.group('year')),
  50. month=int(m.group('month')),
  51. day=1),
  52. int(m.group('index')))
  53. @staticmethod
  54. def time_sequence_filter(date, field_name='date'):
  55. """ Build queryset filter to be used to get the invoices from the
  56. numbering sequence of a given date.
  57. :param field_name: the invoice field name to filter on.
  58. :type date: datetime
  59. :rtype: dict
  60. """
  61. return {'{}__month'.format(field_name): date.month}
  62. class InvoiceQuerySet(models.QuerySet):
  63. def get_next_invoice_number(self, date):
  64. last_invoice_number_str = self._get_last_invoice_number(date)
  65. if last_invoice_number_str is None:
  66. # It's the first bill of the month
  67. invoice_number = InvoiceNumber(date, 1)
  68. else:
  69. invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
  70. return str(invoice_number)
  71. def _get_last_invoice_number(self, date):
  72. same_seq_filter = InvoiceNumber.time_sequence_filter(date)
  73. return self.filter(**same_seq_filter).with_valid_number().aggregate(
  74. models.Max('number'))['number__max']
  75. def with_valid_number(self):
  76. """ Excludes previous numbering schemes or draft invoices
  77. """
  78. return self.filter(number__regex=postgresql_regexp(
  79. InvoiceNumber.RE_INVOICE_NUMBER))
  80. class Invoice(models.Model):
  81. INVOICES_STATUS_CHOICES = (
  82. ('open', 'A payer'),
  83. ('closed', 'Reglée'),
  84. ('trouble', 'Litige')
  85. )
  86. validated = models.BooleanField(default=False, verbose_name='validée',
  87. help_text='Once validated, a PDF is generated'
  88. ' and the invoice cannot be modified')
  89. number = models.CharField(max_length=25,
  90. unique=True,
  91. verbose_name='numéro')
  92. status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
  93. default='open',
  94. verbose_name='statut')
  95. date = models.DateField(
  96. default=datetime.date.today, null=True, verbose_name='date',
  97. help_text='Cette date sera définie à la date de validation dans la facture finale')
  98. date_due = models.DateField(
  99. default=end_of_month,
  100. null=True,
  101. verbose_name="date d'échéance de paiement")
  102. member = models.ForeignKey(Member, null=True, blank=True, default=None,
  103. related_name='invoices',
  104. verbose_name='membre',
  105. on_delete=models.SET_NULL)
  106. pdf = models.FileField(storage=private_files_storage,
  107. upload_to=invoice_pdf_filename,
  108. null=True, blank=True,
  109. verbose_name='PDF')
  110. amount_paid = models.DecimalField(max_digits=5,
  111. decimal_places=2,
  112. default=0,
  113. verbose_name='montant payé')
  114. def save(self, *args, **kwargs):
  115. # First save to get a PK
  116. super(Invoice, self).save(*args, **kwargs)
  117. if not self.validated:
  118. if self.number:
  119. accounting_log.info("Creating/updating draft invoice %s."
  120. % self.number)
  121. if not self.number and self.pk:
  122. # Use pk to build draft invoice number if needed
  123. self.number = 'DRAFT-{}'.format(self.pk)
  124. self.save()
  125. def amount(self):
  126. """
  127. Calcul le montant de la facture
  128. en fonction des éléments de détails
  129. """
  130. total = Decimal('0.0')
  131. for detail in self.details.all():
  132. total += detail.total()
  133. return total.quantize(Decimal('0.01'))
  134. amount.short_description = 'Montant'
  135. def amount_remaining_to_pay(self):
  136. """
  137. Calcul le montant restant à payer
  138. """
  139. return self.amount() - self.amount_paid
  140. amount_remaining_to_pay.short_description = 'Reste à payer'
  141. def has_owner(self, username):
  142. """
  143. Check if passed username (ex gmajax) is owner of the invoice
  144. """
  145. return (self.member and self.member.username == username)
  146. def generate_pdf(self):
  147. """
  148. Make and store a pdf file for the invoice
  149. """
  150. context = {"invoice": self}
  151. context.update(branding(None))
  152. pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
  153. self.pdf.save('%s.pdf' % self.number, pdf_file)
  154. @transaction.atomic
  155. def validate(self):
  156. """
  157. Switch invoice to validate mode. This set to False the draft field
  158. and generate the pdf
  159. """
  160. self.date = datetime.date.today()
  161. old_number = self.number
  162. self.number = Invoice.objects.get_next_invoice_number(self.date)
  163. self.validated = True
  164. self.save()
  165. self.generate_pdf()
  166. accounting_log.info("Draft invoice %s validated as invoice %s. "
  167. "(Total amount : %f ; Member : %s)"
  168. % (old_number, self.number, self.amount(), self.member))
  169. assert self.pdf_exists()
  170. if self.member is not None:
  171. update_accounting_for_member(self.member)
  172. def pdf_exists(self):
  173. return (self.validated
  174. and bool(self.pdf)
  175. and private_files_storage.exists(self.pdf.name))
  176. def get_absolute_url(self):
  177. from django.core.urlresolvers import reverse
  178. return reverse('billing:invoice', args=[self.number])
  179. def __unicode__(self):
  180. return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
  181. class Meta:
  182. verbose_name = 'facture'
  183. objects = InvoiceQuerySet().as_manager()
  184. class InvoiceDetail(models.Model):
  185. label = models.CharField(max_length=100)
  186. amount = models.DecimalField(max_digits=5, decimal_places=2,
  187. verbose_name='montant')
  188. quantity = models.DecimalField(null=True, verbose_name='quantité',
  189. default=1.0, decimal_places=2, max_digits=4)
  190. tax = models.DecimalField(null=True, default=0.0, decimal_places=2,
  191. max_digits=4, verbose_name='TVA',
  192. help_text='en %')
  193. invoice = models.ForeignKey(Invoice, verbose_name='facture',
  194. related_name='details')
  195. offersubscription = models.ForeignKey(OfferSubscription, null=True,
  196. blank=True, default=None,
  197. verbose_name='abonnement')
  198. period_from = models.DateField(
  199. default=start_of_month,
  200. null=True,
  201. blank=True,
  202. verbose_name='début de période',
  203. help_text='Date de début de période sur laquelle est facturé cet item')
  204. period_to = models.DateField(
  205. default=end_of_month,
  206. null=True,
  207. blank=True,
  208. verbose_name='fin de période',
  209. help_text='Date de fin de période sur laquelle est facturé cet item')
  210. def __unicode__(self):
  211. return self.label
  212. def total(self):
  213. """Calcul le total"""
  214. return (self.amount * (self.tax / Decimal('100.0') +
  215. Decimal('1.0')) *
  216. self.quantity).quantize(Decimal('0.01'))
  217. class Meta:
  218. verbose_name = 'détail de facture'
  219. class Payment(models.Model):
  220. PAYMENT_MEAN_CHOICES = (
  221. ('cash', 'Espèces'),
  222. ('check', 'Chèque'),
  223. ('transfer', 'Virement'),
  224. ('other', 'Autre')
  225. )
  226. member = models.ForeignKey(Member, null=True, blank=True, default=None,
  227. related_name='payments',
  228. verbose_name='membre',
  229. on_delete=models.SET_NULL)
  230. payment_mean = models.CharField(max_length=100, null=True,
  231. default='transfer',
  232. choices=PAYMENT_MEAN_CHOICES,
  233. verbose_name='moyen de paiement')
  234. amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
  235. verbose_name='montant')
  236. date = models.DateField(default=datetime.date.today)
  237. invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
  238. blank=True, related_name='payments')
  239. amount_already_allocated = models.DecimalField(max_digits=5,
  240. decimal_places=2,
  241. null=True, blank=True, default=0.0,
  242. verbose_name='montant déjà alloué')
  243. label = models.CharField(max_length=500,
  244. null=True, blank=True, default="",
  245. verbose_name='libellé')
  246. def save(self, *args, **kwargs):
  247. super(Payment, self).save(*args, **kwargs)
  248. # If this payment is related to a member, update the accounting for
  249. # this member
  250. if self.member != None:
  251. update_accounting_for_member(self.member)
  252. def amount_not_allocated(self):
  253. return self.amount - self.amount_already_allocated
  254. @transaction.atomic
  255. def allocate_to_invoice(self, invoice):
  256. amount_can_pay = self.amount_not_allocated()
  257. amount_to_pay = invoice.amount_remaining_to_pay()
  258. amount_to_allocate = min(amount_can_pay, amount_to_pay)
  259. accounting_log.info("Allocating %f from payment %s to invoice %s"
  260. % (float(amount_to_allocate), str(self.date),
  261. invoice.number))
  262. self.amount_already_allocated += amount_to_allocate
  263. invoice.amount_paid += amount_to_allocate
  264. # Close invoice if relevant
  265. if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
  266. accounting_log.info("Invoice %s has been paid and is now closed"
  267. % invoice.number)
  268. invoice.status = "closed"
  269. invoice.save()
  270. self.save()
  271. @property
  272. def name(self):
  273. return str(self)
  274. def __unicode__(self):
  275. if self.member is not None:
  276. return 'Paiment de %0.2f€ le %s par %s' \
  277. % (self.amount, str(self.date), self.member)
  278. else:
  279. return 'Paiment de %0.2f€ le %s' \
  280. % (self.amount, str(self.date))
  281. class Meta:
  282. verbose_name = 'paiement'
  283. def update_accounting_for_member(member):
  284. """
  285. Met à jour le status des factures, des paiements et le solde du compte
  286. d'un utilisateur
  287. """
  288. accounting_log.info("Member %s current balance is %f ..."
  289. % (str(member), float(member.balance)))
  290. accounting_log.info("Updating accounting for member %s ..."
  291. % str(member))
  292. # Fetch relevant and active payments / invoices
  293. # and sort then by chronological order : olders first, newers last.
  294. this_member_invoices = [i for i in member.invoices.filter(validated=True)
  295. .order_by("date")]
  296. this_member_payments = [p for p in member.payments.order_by("date")]
  297. # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
  298. # conflict / trouble invoices)
  299. number_of_active_payments = len([p for p in this_member_payments if p.amount_not_allocated() > 0])
  300. number_of_active_invoices = len([p for p in this_member_invoices if p.amount_remaining_to_pay() > 0])
  301. if (number_of_active_payments == 0):
  302. accounting_log.info("(No active payment for %s. No invoice/payment "
  303. "reconciliation needed for now.)."
  304. % str(member))
  305. elif (number_of_active_invoices == 0):
  306. accounting_log.info("(No active invoice for %s. No invoice/payment "
  307. "reconciliation needed for now.)."
  308. % str(member))
  309. else:
  310. accounting_log.info("Initiating reconciliation between "
  311. "invoice and payments for %s" % str(member))
  312. reconcile_invoices_and_payments(this_member_invoices,
  313. this_member_payments)
  314. member.balance = compute_balance(this_member_invoices,
  315. this_member_payments)
  316. member.save()
  317. accounting_log.info("Member %s new balance is %f"
  318. % (str(member), float(member.balance)))
  319. def reconcile_invoices_and_payments(invoices, payments):
  320. """
  321. Rapproche des factures et des paiements qui sont actifs (paiement non alloué
  322. ou factures non entièrement payées) automatiquement.
  323. Retourne la balance restante (positive si 'trop payé', négative si factures
  324. restantes à payer)
  325. """
  326. active_payments = [p for p in payments if p.amount_not_allocated() > 0]
  327. active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
  328. if (len(active_payments) == 0):
  329. accounting_log.info("No more active payment. Nothing to reconcile "
  330. "anymore.")
  331. return
  332. if (len(active_invoices) == 0):
  333. accounting_log.info("No more active invoice. Nothing to reconcile "
  334. "anymore.")
  335. return
  336. # Only consider the oldest active payment and the oldest active invoice
  337. p = active_payments[0]
  338. i = active_invoices[0]
  339. # TODO : should add an assert that the ammount not allocated / remaining to
  340. # pay is lower before and after calling the allocate_to_invoice
  341. p.allocate_to_invoice(i)
  342. # Reconcicle next payment / invoice
  343. reconcile_invoices_and_payments(invoices, payments)
  344. def compute_balance(invoices, payments):
  345. active_payments = [p for p in payments if p.amount_not_allocated() > 0]
  346. active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
  347. s = 0
  348. s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
  349. s += sum([p.amount_not_allocated() for p in active_payments])
  350. return s
  351. def test_accounting_update():
  352. Member.objects.all().delete()
  353. Payment.objects.all().delete()
  354. Invoice.objects.all().delete()
  355. johndoe = Member.objects.create(username="johndoe",
  356. first_name="John",
  357. last_name="Doe",
  358. email="johndoe@yolo.test")
  359. johndoe.set_password("trololo")
  360. # First facture
  361. invoice = Invoice.objects.create(number="1337",
  362. member=johndoe)
  363. InvoiceDetail.objects.create(label="superservice",
  364. amount="15.0",
  365. invoice=invoice)
  366. invoice.validate()
  367. # Second facture
  368. invoice2 = Invoice.objects.create(number="42",
  369. member=johndoe)
  370. InvoiceDetail.objects.create(label="superservice",
  371. amount="42",
  372. invoice=invoice2)
  373. invoice2.validate()
  374. # Payment
  375. payment = Payment.objects.create(amount=20,
  376. member=johndoe)
  377. Member.objects.all().delete()
  378. Payment.objects.all().delete()
  379. Invoice.objects.all().delete()