models.py 27 KB

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