|
@@ -20,9 +20,14 @@ from django.core.urlresolvers import reverse
|
|
from coin.offers.models import OfferSubscription
|
|
from coin.offers.models import OfferSubscription
|
|
from coin.members.models import Member
|
|
from coin.members.models import Member
|
|
from coin.html2pdf import render_as_pdf
|
|
from coin.html2pdf import render_as_pdf
|
|
-from coin.utils import private_files_storage, start_of_month, end_of_month, \
|
|
|
|
- postgresql_regexp, send_templated_email, \
|
|
|
|
- disable_for_loaddata
|
|
|
|
|
|
+from coin.utils import (
|
|
|
|
+ private_files_storage,
|
|
|
|
+ start_of_month,
|
|
|
|
+ end_of_month,
|
|
|
|
+ postgresql_regexp,
|
|
|
|
+ send_templated_email,
|
|
|
|
+ disable_for_loaddata,
|
|
|
|
+)
|
|
from coin.isp_database.context_processors import branding
|
|
from coin.isp_database.context_processors import branding
|
|
from coin.isp_database.models import ISPInfo
|
|
from coin.isp_database.models import ISPInfo
|
|
|
|
|
|
@@ -32,9 +37,7 @@ accounting_log = logging.getLogger("coin.billing")
|
|
def invoice_pdf_filename(instance, filename):
|
|
def invoice_pdf_filename(instance, filename):
|
|
"""Nom et chemin du fichier pdf à stocker pour les factures"""
|
|
"""Nom et chemin du fichier pdf à stocker pour les factures"""
|
|
member_id = instance.member.id if instance.member else 0
|
|
member_id = instance.member.id if instance.member else 0
|
|
- return 'invoices/%d_%s_%s.pdf' % (member_id,
|
|
|
|
- instance.number,
|
|
|
|
- uuid.uuid4())
|
|
|
|
|
|
+ return "invoices/%d_%s_%s.pdf" % (member_id, instance.number, uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
@python_2_unicode_compatible
|
|
@python_2_unicode_compatible
|
|
@@ -50,8 +53,8 @@ class InvoiceNumber:
|
|
- MM month of the bill
|
|
- MM month of the bill
|
|
- XXXXXX a per-month sequence
|
|
- XXXXXX a per-month sequence
|
|
"""
|
|
"""
|
|
- RE_INVOICE_NUMBER = re.compile(
|
|
|
|
- r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
|
|
|
|
|
|
+
|
|
|
|
+ RE_INVOICE_NUMBER = re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})")
|
|
|
|
|
|
def __init__(self, date, index):
|
|
def __init__(self, date, index):
|
|
self.date = date
|
|
self.date = date
|
|
@@ -61,7 +64,7 @@ class InvoiceNumber:
|
|
return InvoiceNumber(self.date, self.index + 1)
|
|
return InvoiceNumber(self.date, self.index + 1)
|
|
|
|
|
|
def __str__(self):
|
|
def __str__(self):
|
|
- return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
|
|
|
|
|
|
+ return "{:%Y-%m}-{:0>6}".format(self.date, self.index)
|
|
|
|
|
|
@classmethod
|
|
@classmethod
|
|
def parse(cls, string):
|
|
def parse(cls, string):
|
|
@@ -71,13 +74,13 @@ class InvoiceNumber:
|
|
|
|
|
|
return cls(
|
|
return cls(
|
|
datetime.date(
|
|
datetime.date(
|
|
- year=int(m.group('year')),
|
|
|
|
- month=int(m.group('month')),
|
|
|
|
- day=1),
|
|
|
|
- int(m.group('index')))
|
|
|
|
|
|
+ year=int(m.group("year")), month=int(m.group("month")), day=1
|
|
|
|
+ ),
|
|
|
|
+ int(m.group("index")),
|
|
|
|
+ )
|
|
|
|
|
|
@staticmethod
|
|
@staticmethod
|
|
- def time_sequence_filter(date, field_name='date'):
|
|
|
|
|
|
+ def time_sequence_filter(date, field_name="date"):
|
|
""" Build queryset filter to be used to get the invoices from the
|
|
""" Build queryset filter to be used to get the invoices from the
|
|
numbering sequence of a given date.
|
|
numbering sequence of a given date.
|
|
|
|
|
|
@@ -88,8 +91,8 @@ class InvoiceNumber:
|
|
"""
|
|
"""
|
|
|
|
|
|
return {
|
|
return {
|
|
- '{}__month'.format(field_name): date.month,
|
|
|
|
- '{}__year'.format(field_name): date.year
|
|
|
|
|
|
+ "{}__month".format(field_name): date.month,
|
|
|
|
+ "{}__year".format(field_name): date.year,
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -107,58 +110,82 @@ class InvoiceQuerySet(models.QuerySet):
|
|
|
|
|
|
def _get_last_invoice_number(self, date):
|
|
def _get_last_invoice_number(self, date):
|
|
same_seq_filter = InvoiceNumber.time_sequence_filter(date)
|
|
same_seq_filter = InvoiceNumber.time_sequence_filter(date)
|
|
- return self.filter(**same_seq_filter).with_valid_number().aggregate(
|
|
|
|
- models.Max('number'))['number__max']
|
|
|
|
|
|
+ return (
|
|
|
|
+ self.filter(**same_seq_filter)
|
|
|
|
+ .with_valid_number()
|
|
|
|
+ .aggregate(models.Max("number"))["number__max"]
|
|
|
|
+ )
|
|
|
|
|
|
def with_valid_number(self):
|
|
def with_valid_number(self):
|
|
""" Excludes previous numbering schemes or draft invoices
|
|
""" Excludes previous numbering schemes or draft invoices
|
|
"""
|
|
"""
|
|
- return self.filter(number__regex=postgresql_regexp(
|
|
|
|
- InvoiceNumber.RE_INVOICE_NUMBER))
|
|
|
|
|
|
+ return self.filter(
|
|
|
|
+ number__regex=postgresql_regexp(InvoiceNumber.RE_INVOICE_NUMBER)
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
class Invoice(models.Model):
|
|
class Invoice(models.Model):
|
|
|
|
|
|
INVOICES_STATUS_CHOICES = (
|
|
INVOICES_STATUS_CHOICES = (
|
|
- ('open', 'À payer'),
|
|
|
|
- ('closed', 'Réglée'),
|
|
|
|
- ('trouble', 'Litige')
|
|
|
|
|
|
+ ("open", "À payer"),
|
|
|
|
+ ("closed", "Réglée"),
|
|
|
|
+ ("trouble", "Litige"),
|
|
)
|
|
)
|
|
|
|
|
|
- validated = models.BooleanField(default=False, verbose_name='validée',
|
|
|
|
- help_text='Once validated, a PDF is generated'
|
|
|
|
- ' and the invoice cannot be modified')
|
|
|
|
- number = models.CharField(max_length=25,
|
|
|
|
- unique=True,
|
|
|
|
- verbose_name='numéro')
|
|
|
|
- status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
|
|
|
|
- default='open',
|
|
|
|
- verbose_name='statut')
|
|
|
|
|
|
+ validated = models.BooleanField(
|
|
|
|
+ default=False,
|
|
|
|
+ verbose_name="validée",
|
|
|
|
+ help_text="Once validated, a PDF is generated"
|
|
|
|
+ " and the invoice cannot be modified",
|
|
|
|
+ )
|
|
|
|
+ number = models.CharField(max_length=25, unique=True, verbose_name="numéro")
|
|
|
|
+ status = models.CharField(
|
|
|
|
+ max_length=50,
|
|
|
|
+ choices=INVOICES_STATUS_CHOICES,
|
|
|
|
+ default="open",
|
|
|
|
+ verbose_name="statut",
|
|
|
|
+ )
|
|
date = models.DateField(
|
|
date = models.DateField(
|
|
- default=datetime.date.today, null=True, verbose_name='date',
|
|
|
|
- help_text='Cette date sera définie à la date de validation dans la facture finale')
|
|
|
|
|
|
+ default=datetime.date.today,
|
|
|
|
+ null=True,
|
|
|
|
+ verbose_name="date",
|
|
|
|
+ help_text="Cette date sera définie à la date de validation dans la facture finale",
|
|
|
|
+ )
|
|
date_due = models.DateField(
|
|
date_due = models.DateField(
|
|
- null=True, blank=True,
|
|
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
verbose_name="date d'échéance de paiement",
|
|
verbose_name="date d'échéance de paiement",
|
|
- help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
|
|
|
|
- member = models.ForeignKey(Member, null=True, blank=True, default=None,
|
|
|
|
- related_name='invoices',
|
|
|
|
- verbose_name='membre',
|
|
|
|
- on_delete=models.SET_NULL)
|
|
|
|
- pdf = models.FileField(storage=private_files_storage,
|
|
|
|
- upload_to=invoice_pdf_filename,
|
|
|
|
- null=True, blank=True,
|
|
|
|
- verbose_name='PDF')
|
|
|
|
-
|
|
|
|
- date_last_reminder_email = models.DateTimeField(null=True, blank=True,
|
|
|
|
- verbose_name="Date du dernier email de relance envoyé")
|
|
|
|
|
|
+ help_text="Le délai de paiement sera fixé à {} jours à la validation si laissé vide".format(
|
|
|
|
+ settings.PAYMENT_DELAY
|
|
|
|
+ ),
|
|
|
|
+ )
|
|
|
|
+ member = models.ForeignKey(
|
|
|
|
+ Member,
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
|
|
+ default=None,
|
|
|
|
+ related_name="invoices",
|
|
|
|
+ verbose_name="membre",
|
|
|
|
+ on_delete=models.SET_NULL,
|
|
|
|
+ )
|
|
|
|
+ pdf = models.FileField(
|
|
|
|
+ storage=private_files_storage,
|
|
|
|
+ upload_to=invoice_pdf_filename,
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
|
|
+ verbose_name="PDF",
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ date_last_reminder_email = models.DateTimeField(
|
|
|
|
+ null=True, blank=True, verbose_name="Date du dernier email de relance envoyé"
|
|
|
|
+ )
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
def save(self, *args, **kwargs):
|
|
# First save to get a PK
|
|
# First save to get a PK
|
|
super(Invoice, self).save(*args, **kwargs)
|
|
super(Invoice, self).save(*args, **kwargs)
|
|
# Then use that pk to build draft invoice number
|
|
# Then use that pk to build draft invoice number
|
|
if not self.validated and self.pk and not self.number:
|
|
if not self.validated and self.pk and not self.number:
|
|
- self.number = 'DRAFT-{}'.format(self.pk)
|
|
|
|
|
|
+ self.number = "DRAFT-{}".format(self.pk)
|
|
self.save()
|
|
self.save()
|
|
|
|
|
|
def amount(self):
|
|
def amount(self):
|
|
@@ -166,38 +193,42 @@ class Invoice(models.Model):
|
|
Calcul le montant de la facture
|
|
Calcul le montant de la facture
|
|
en fonction des éléments de détails
|
|
en fonction des éléments de détails
|
|
"""
|
|
"""
|
|
- total = Decimal('0.0')
|
|
|
|
|
|
+ total = Decimal("0.0")
|
|
for detail in self.details.all():
|
|
for detail in self.details.all():
|
|
total += detail.total()
|
|
total += detail.total()
|
|
- return total.quantize(Decimal('0.01'))
|
|
|
|
- amount.short_description = 'Montant'
|
|
|
|
|
|
+ return total.quantize(Decimal("0.01"))
|
|
|
|
+
|
|
|
|
+ amount.short_description = "Montant"
|
|
|
|
|
|
def amount_before_tax(self):
|
|
def amount_before_tax(self):
|
|
- total = Decimal('0.0')
|
|
|
|
|
|
+ total = Decimal("0.0")
|
|
for detail in self.details.all():
|
|
for detail in self.details.all():
|
|
total += detail.amount
|
|
total += detail.amount
|
|
- return total.quantize(Decimal('0.01'))
|
|
|
|
- amount_before_tax.short_description = 'Montant HT'
|
|
|
|
|
|
+ return total.quantize(Decimal("0.01"))
|
|
|
|
+
|
|
|
|
+ amount_before_tax.short_description = "Montant HT"
|
|
|
|
|
|
def amount_paid(self):
|
|
def amount_paid(self):
|
|
"""
|
|
"""
|
|
Calcul le montant déjà payé à partir des allocations de paiements
|
|
Calcul le montant déjà payé à partir des allocations de paiements
|
|
"""
|
|
"""
|
|
return sum([a.amount for a in self.allocations.all()])
|
|
return sum([a.amount for a in self.allocations.all()])
|
|
- amount_paid.short_description = 'Montant payé'
|
|
|
|
|
|
+
|
|
|
|
+ amount_paid.short_description = "Montant payé"
|
|
|
|
|
|
def amount_remaining_to_pay(self):
|
|
def amount_remaining_to_pay(self):
|
|
"""
|
|
"""
|
|
Calcul le montant restant à payer
|
|
Calcul le montant restant à payer
|
|
"""
|
|
"""
|
|
return self.amount() - self.amount_paid()
|
|
return self.amount() - self.amount_paid()
|
|
- amount_remaining_to_pay.short_description = 'Reste à payer'
|
|
|
|
|
|
+
|
|
|
|
+ amount_remaining_to_pay.short_description = "Reste à payer"
|
|
|
|
|
|
def has_owner(self, username):
|
|
def has_owner(self, username):
|
|
"""
|
|
"""
|
|
Check if passed username (ex gmajax) is owner of the invoice
|
|
Check if passed username (ex gmajax) is owner of the invoice
|
|
"""
|
|
"""
|
|
- return (self.member and self.member.username == username)
|
|
|
|
|
|
+ return self.member and self.member.username == username
|
|
|
|
|
|
def generate_pdf(self):
|
|
def generate_pdf(self):
|
|
"""
|
|
"""
|
|
@@ -205,8 +236,8 @@ class Invoice(models.Model):
|
|
"""
|
|
"""
|
|
context = {"invoice": self}
|
|
context = {"invoice": self}
|
|
context.update(branding(None))
|
|
context.update(branding(None))
|
|
- pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
|
|
|
|
- self.pdf.save('%s.pdf' % self.number, pdf_file)
|
|
|
|
|
|
+ pdf_file = render_as_pdf("billing/invoice_pdf.html", context)
|
|
|
|
+ self.pdf.save("%s.pdf" % self.number, pdf_file)
|
|
|
|
|
|
@transaction.atomic
|
|
@transaction.atomic
|
|
def validate(self):
|
|
def validate(self):
|
|
@@ -225,26 +256,25 @@ class Invoice(models.Model):
|
|
self.generate_pdf()
|
|
self.generate_pdf()
|
|
|
|
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "Draft invoice {} validated as invoice {}. ".format(
|
|
|
|
- old_number, self.number) +
|
|
|
|
- "(Total amount : {} ; Member : {})".format(
|
|
|
|
- self.amount(), self.member))
|
|
|
|
|
|
+ "Draft invoice {} validated as invoice {}. ".format(old_number, self.number)
|
|
|
|
+ + "(Total amount : {} ; Member : {})".format(self.amount(), self.member)
|
|
|
|
+ )
|
|
assert self.pdf_exists()
|
|
assert self.pdf_exists()
|
|
if self.member is not None:
|
|
if self.member is not None:
|
|
update_accounting_for_member(self.member)
|
|
update_accounting_for_member(self.member)
|
|
|
|
|
|
-
|
|
|
|
def pdf_exists(self):
|
|
def pdf_exists(self):
|
|
- return (self.validated
|
|
|
|
- and bool(self.pdf)
|
|
|
|
- and private_files_storage.exists(self.pdf.name))
|
|
|
|
|
|
+ return (
|
|
|
|
+ self.validated
|
|
|
|
+ and bool(self.pdf)
|
|
|
|
+ and private_files_storage.exists(self.pdf.name)
|
|
|
|
+ )
|
|
|
|
|
|
def get_absolute_url(self):
|
|
def get_absolute_url(self):
|
|
- return reverse('billing:invoice', args=[self.number])
|
|
|
|
|
|
+ return reverse("billing:invoice", args=[self.number])
|
|
|
|
|
|
def __unicode__(self):
|
|
def __unicode__(self):
|
|
- return '#{} {:0.2f}€ {}'.format(
|
|
|
|
- self.number, self.amount(), self.date_due)
|
|
|
|
|
|
+ return "#{} {:0.2f}€ {}".format(self.number, self.amount(), self.date_due)
|
|
|
|
|
|
def reminder_needed(self):
|
|
def reminder_needed(self):
|
|
|
|
|
|
@@ -253,17 +283,17 @@ class Invoice(models.Model):
|
|
return False
|
|
return False
|
|
|
|
|
|
# If bill is close or not validated yet, nope
|
|
# If bill is close or not validated yet, nope
|
|
- if self.status != 'open' or not self.validated:
|
|
|
|
|
|
+ if self.status != "open" or not self.validated:
|
|
return False
|
|
return False
|
|
|
|
|
|
# If bill is not at least one month old, nope
|
|
# If bill is not at least one month old, nope
|
|
- if self.date_due >= timezone.now()+relativedelta(weeks=-4):
|
|
|
|
|
|
+ if self.date_due >= timezone.now() + relativedelta(weeks=-4):
|
|
return False
|
|
return False
|
|
|
|
|
|
# If a reminder has been recently sent, nope
|
|
# If a reminder has been recently sent, nope
|
|
- if (self.date_last_reminder_email
|
|
|
|
- and (self.date_last_reminder_email
|
|
|
|
- >= timezone.now() + relativedelta(weeks=-3))):
|
|
|
|
|
|
+ if self.date_last_reminder_email and (
|
|
|
|
+ self.date_last_reminder_email >= timezone.now() + relativedelta(weeks=-3)
|
|
|
|
+ ):
|
|
return False
|
|
return False
|
|
|
|
|
|
return True
|
|
return True
|
|
@@ -281,25 +311,31 @@ class Invoice(models.Model):
|
|
|
|
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Sending reminder email to {} to pay invoice {}".format(
|
|
"Sending reminder email to {} to pay invoice {}".format(
|
|
- self.member, str(self.number)))
|
|
|
|
|
|
+ self.member, str(self.number)
|
|
|
|
+ )
|
|
|
|
+ )
|
|
|
|
|
|
isp_info = ISPInfo.objects.first()
|
|
isp_info = ISPInfo.objects.first()
|
|
kwargs = {}
|
|
kwargs = {}
|
|
# Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
|
|
# Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
|
|
if isp_info and isp_info.administrative_email:
|
|
if isp_info and isp_info.administrative_email:
|
|
- kwargs['from_email'] = isp_info.administrative_email
|
|
|
|
|
|
+ kwargs["from_email"] = isp_info.administrative_email
|
|
|
|
|
|
# Si le dernier courriel de relance a été envoyé il y a moins de trois
|
|
# Si le dernier courriel de relance a été envoyé il y a moins de trois
|
|
# semaines, n'envoi pas un nouveau courriel
|
|
# semaines, n'envoi pas un nouveau courriel
|
|
send_templated_email(
|
|
send_templated_email(
|
|
to=self.member.email,
|
|
to=self.member.email,
|
|
- subject_template='billing/emails/reminder_for_unpaid_bill.txt',
|
|
|
|
- body_template='billing/emails/reminder_for_unpaid_bill.html',
|
|
|
|
- context={'member': self.member, 'branding': isp_info,
|
|
|
|
- 'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
|
|
|
|
- 'today': datetime.date.today,
|
|
|
|
- 'auto_sent': auto},
|
|
|
|
- **kwargs)
|
|
|
|
|
|
+ subject_template="billing/emails/reminder_for_unpaid_bill.txt",
|
|
|
|
+ body_template="billing/emails/reminder_for_unpaid_bill.html",
|
|
|
|
+ context={
|
|
|
|
+ "member": self.member,
|
|
|
|
+ "branding": isp_info,
|
|
|
|
+ "membership_info_url": settings.MEMBER_MEMBERSHIP_INFO_URL,
|
|
|
|
+ "today": datetime.date.today,
|
|
|
|
+ "auto_sent": auto,
|
|
|
|
+ },
|
|
|
|
+ **kwargs
|
|
|
|
+ )
|
|
|
|
|
|
# Sauvegarde en base la date du dernier envoi de mail de relance
|
|
# Sauvegarde en base la date du dernier envoi de mail de relance
|
|
self.date_last_reminder_email = timezone.now()
|
|
self.date_last_reminder_email = timezone.now()
|
|
@@ -307,7 +343,7 @@ class Invoice(models.Model):
|
|
return True
|
|
return True
|
|
|
|
|
|
class Meta:
|
|
class Meta:
|
|
- verbose_name = 'facture'
|
|
|
|
|
|
+ verbose_name = "facture"
|
|
|
|
|
|
objects = InvoiceQuerySet().as_manager()
|
|
objects = InvoiceQuerySet().as_manager()
|
|
|
|
|
|
@@ -315,71 +351,95 @@ class Invoice(models.Model):
|
|
class InvoiceDetail(models.Model):
|
|
class InvoiceDetail(models.Model):
|
|
|
|
|
|
label = models.CharField(max_length=100)
|
|
label = models.CharField(max_length=100)
|
|
- amount = models.DecimalField(max_digits=5, decimal_places=2,
|
|
|
|
- verbose_name='montant')
|
|
|
|
- quantity = models.DecimalField(null=True, verbose_name='quantité',
|
|
|
|
- default=1.0, decimal_places=2, max_digits=4)
|
|
|
|
- tax = models.DecimalField(null=True, default=0.0, decimal_places=2,
|
|
|
|
- max_digits=4, verbose_name='TVA',
|
|
|
|
- help_text='en %')
|
|
|
|
- invoice = models.ForeignKey(Invoice, verbose_name='facture',
|
|
|
|
- related_name='details')
|
|
|
|
- offersubscription = models.ForeignKey(OfferSubscription, null=True,
|
|
|
|
- blank=True, default=None,
|
|
|
|
- verbose_name='abonnement')
|
|
|
|
|
|
+ amount = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="montant")
|
|
|
|
+ quantity = models.DecimalField(
|
|
|
|
+ null=True, verbose_name="quantité", default=1.0, decimal_places=2, max_digits=4
|
|
|
|
+ )
|
|
|
|
+ tax = models.DecimalField(
|
|
|
|
+ null=True,
|
|
|
|
+ default=0.0,
|
|
|
|
+ decimal_places=2,
|
|
|
|
+ max_digits=4,
|
|
|
|
+ verbose_name="TVA",
|
|
|
|
+ help_text="en %",
|
|
|
|
+ )
|
|
|
|
+ invoice = models.ForeignKey(Invoice, verbose_name="facture", related_name="details")
|
|
|
|
+ offersubscription = models.ForeignKey(
|
|
|
|
+ OfferSubscription,
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
|
|
+ default=None,
|
|
|
|
+ verbose_name="abonnement",
|
|
|
|
+ )
|
|
period_from = models.DateField(
|
|
period_from = models.DateField(
|
|
default=start_of_month,
|
|
default=start_of_month,
|
|
null=True,
|
|
null=True,
|
|
blank=True,
|
|
blank=True,
|
|
- verbose_name='début de période',
|
|
|
|
- help_text='Date de début de période sur laquelle est facturé cet item')
|
|
|
|
|
|
+ verbose_name="début de période",
|
|
|
|
+ help_text="Date de début de période sur laquelle est facturé cet item",
|
|
|
|
+ )
|
|
period_to = models.DateField(
|
|
period_to = models.DateField(
|
|
default=end_of_month,
|
|
default=end_of_month,
|
|
null=True,
|
|
null=True,
|
|
blank=True,
|
|
blank=True,
|
|
- verbose_name='fin de période',
|
|
|
|
- help_text='Date de fin de période sur laquelle est facturé cet item')
|
|
|
|
|
|
+ verbose_name="fin de période",
|
|
|
|
+ help_text="Date de fin de période sur laquelle est facturé cet item",
|
|
|
|
+ )
|
|
|
|
|
|
def __unicode__(self):
|
|
def __unicode__(self):
|
|
return self.label
|
|
return self.label
|
|
|
|
|
|
def total(self):
|
|
def total(self):
|
|
"""Calcul le total"""
|
|
"""Calcul le total"""
|
|
- return (self.amount * (self.tax / Decimal('100.0') +
|
|
|
|
- Decimal('1.0')) *
|
|
|
|
- self.quantity).quantize(Decimal('0.01'))
|
|
|
|
|
|
+ return (
|
|
|
|
+ self.amount * (self.tax / Decimal("100.0") + Decimal("1.0")) * self.quantity
|
|
|
|
+ ).quantize(Decimal("0.01"))
|
|
|
|
|
|
class Meta:
|
|
class Meta:
|
|
- verbose_name = 'détail de facture'
|
|
|
|
|
|
+ verbose_name = "détail de facture"
|
|
|
|
|
|
|
|
|
|
class Payment(models.Model):
|
|
class Payment(models.Model):
|
|
|
|
|
|
PAYMENT_MEAN_CHOICES = (
|
|
PAYMENT_MEAN_CHOICES = (
|
|
- ('cash', 'Espèces'),
|
|
|
|
- ('check', 'Chèque'),
|
|
|
|
- ('transfer', 'Virement'),
|
|
|
|
- ('other', 'Autre')
|
|
|
|
|
|
+ ("cash", "Espèces"),
|
|
|
|
+ ("check", "Chèque"),
|
|
|
|
+ ("transfer", "Virement"),
|
|
|
|
+ ("other", "Autre"),
|
|
)
|
|
)
|
|
|
|
|
|
- member = models.ForeignKey(Member, null=True, blank=True, default=None,
|
|
|
|
- related_name='payments',
|
|
|
|
- verbose_name='membre',
|
|
|
|
- on_delete=models.SET_NULL)
|
|
|
|
-
|
|
|
|
- payment_mean = models.CharField(max_length=100, null=True,
|
|
|
|
- default='transfer',
|
|
|
|
- choices=PAYMENT_MEAN_CHOICES,
|
|
|
|
- verbose_name='moyen de paiement')
|
|
|
|
- amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
|
|
|
|
- verbose_name='montant')
|
|
|
|
|
|
+ member = models.ForeignKey(
|
|
|
|
+ Member,
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
|
|
+ default=None,
|
|
|
|
+ related_name="payments",
|
|
|
|
+ verbose_name="membre",
|
|
|
|
+ on_delete=models.SET_NULL,
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ payment_mean = models.CharField(
|
|
|
|
+ max_length=100,
|
|
|
|
+ null=True,
|
|
|
|
+ default="transfer",
|
|
|
|
+ choices=PAYMENT_MEAN_CHOICES,
|
|
|
|
+ verbose_name="moyen de paiement",
|
|
|
|
+ )
|
|
|
|
+ amount = models.DecimalField(
|
|
|
|
+ max_digits=5, decimal_places=2, null=True, verbose_name="montant"
|
|
|
|
+ )
|
|
date = models.DateField(default=datetime.date.today)
|
|
date = models.DateField(default=datetime.date.today)
|
|
- invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
|
|
|
|
- blank=True, related_name='payments')
|
|
|
|
|
|
+ invoice = models.ForeignKey(
|
|
|
|
+ Invoice,
|
|
|
|
+ verbose_name="facture associée",
|
|
|
|
+ null=True,
|
|
|
|
+ blank=True,
|
|
|
|
+ related_name="payments",
|
|
|
|
+ )
|
|
|
|
|
|
- label = models.CharField(max_length=500,
|
|
|
|
- null=True, blank=True, default="",
|
|
|
|
- verbose_name='libellé')
|
|
|
|
|
|
+ label = models.CharField(
|
|
|
|
+ max_length=500, null=True, blank=True, default="", verbose_name="libellé"
|
|
|
|
+ )
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
def save(self, *args, **kwargs):
|
|
|
|
|
|
@@ -393,7 +453,6 @@ class Payment(models.Model):
|
|
|
|
|
|
super(Payment, self).save(*args, **kwargs)
|
|
super(Payment, self).save(*args, **kwargs)
|
|
|
|
|
|
-
|
|
|
|
def clean(self):
|
|
def clean(self):
|
|
|
|
|
|
# Only if no amount already alloca ted...
|
|
# Only if no amount already alloca ted...
|
|
@@ -402,10 +461,12 @@ class Payment(models.Model):
|
|
# If there's a linked invoice and this payment would pay more than
|
|
# If there's a linked invoice and this payment would pay more than
|
|
# the remaining amount needed to pay the invoice...
|
|
# the remaining amount needed to pay the invoice...
|
|
if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
|
|
if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
|
|
- raise ValidationError("This payment would pay more than the invoice's remaining to pay")
|
|
|
|
|
|
+ raise ValidationError(
|
|
|
|
+ "This payment would pay more than the invoice's remaining to pay"
|
|
|
|
+ )
|
|
|
|
|
|
def amount_already_allocated(self):
|
|
def amount_already_allocated(self):
|
|
- return sum([ a.amount for a in self.allocations.all() ])
|
|
|
|
|
|
+ return sum([a.amount for a in self.allocations.all()])
|
|
|
|
|
|
def amount_not_allocated(self):
|
|
def amount_not_allocated(self):
|
|
return self.amount - self.amount_already_allocated()
|
|
return self.amount - self.amount_already_allocated()
|
|
@@ -417,22 +478,24 @@ class Payment(models.Model):
|
|
# ...
|
|
# ...
|
|
|
|
|
|
amount_can_pay = self.amount_not_allocated()
|
|
amount_can_pay = self.amount_not_allocated()
|
|
- amount_to_pay = invoice.amount_remaining_to_pay()
|
|
|
|
|
|
+ amount_to_pay = invoice.amount_remaining_to_pay()
|
|
amount_to_allocate = min(amount_can_pay, amount_to_pay)
|
|
amount_to_allocate = min(amount_can_pay, amount_to_pay)
|
|
|
|
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Allocating {} from payment {} to invoice {}".format(
|
|
"Allocating {} from payment {} to invoice {}".format(
|
|
- amount_to_allocate, self.date, invoice.number))
|
|
|
|
|
|
+ amount_to_allocate, self.date, invoice.number
|
|
|
|
+ )
|
|
|
|
+ )
|
|
|
|
|
|
- PaymentAllocation.objects.create(invoice=invoice,
|
|
|
|
- payment=self,
|
|
|
|
- amount=amount_to_allocate)
|
|
|
|
|
|
+ PaymentAllocation.objects.create(
|
|
|
|
+ invoice=invoice, payment=self, amount=amount_to_allocate
|
|
|
|
+ )
|
|
|
|
|
|
# Close invoice if relevant
|
|
# Close invoice if relevant
|
|
if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
|
|
if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "Invoice {} has been paid and is now closed".format(
|
|
|
|
- invoice.number))
|
|
|
|
|
|
+ "Invoice {} has been paid and is now closed".format(invoice.number)
|
|
|
|
+ )
|
|
invoice.status = "closed"
|
|
invoice.status = "closed"
|
|
|
|
|
|
invoice.save()
|
|
invoice.save()
|
|
@@ -440,14 +503,14 @@ class Payment(models.Model):
|
|
|
|
|
|
def __unicode__(self):
|
|
def __unicode__(self):
|
|
if self.member is not None:
|
|
if self.member is not None:
|
|
- return 'Paiment de {:0.2f}€ le {} par {}'.format(
|
|
|
|
- self.amount, self.date, self.member)
|
|
|
|
|
|
+ return "Paiment de {:0.2f}€ le {} par {}".format(
|
|
|
|
+ self.amount, self.date, self.member
|
|
|
|
+ )
|
|
else:
|
|
else:
|
|
- return 'Paiment de {:0.2f}€ le {}'.format(
|
|
|
|
- self.amount, self.date)
|
|
|
|
|
|
+ return "Paiment de {:0.2f}€ le {}".format(self.amount, self.date)
|
|
|
|
|
|
class Meta:
|
|
class Meta:
|
|
- verbose_name = 'paiement'
|
|
|
|
|
|
+ verbose_name = "paiement"
|
|
|
|
|
|
|
|
|
|
# This corresponds to a (possibly partial) allocation of a given payment to
|
|
# This corresponds to a (possibly partial) allocation of a given payment to
|
|
@@ -456,14 +519,23 @@ class Payment(models.Model):
|
|
# There can be for example an allocation of 3.14€ from P to I.
|
|
# There can be for example an allocation of 3.14€ from P to I.
|
|
class PaymentAllocation(models.Model):
|
|
class PaymentAllocation(models.Model):
|
|
|
|
|
|
- invoice = models.ForeignKey(Invoice, verbose_name='facture associée',
|
|
|
|
- null=False, blank=False,
|
|
|
|
- related_name='allocations')
|
|
|
|
- payment = models.ForeignKey(Payment, verbose_name='facture associée',
|
|
|
|
- null=False, blank=False,
|
|
|
|
- related_name='allocations')
|
|
|
|
- amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
|
|
|
|
- verbose_name='montant')
|
|
|
|
|
|
+ invoice = models.ForeignKey(
|
|
|
|
+ Invoice,
|
|
|
|
+ verbose_name="facture associée",
|
|
|
|
+ null=False,
|
|
|
|
+ blank=False,
|
|
|
|
+ related_name="allocations",
|
|
|
|
+ )
|
|
|
|
+ payment = models.ForeignKey(
|
|
|
|
+ Payment,
|
|
|
|
+ verbose_name="facture associée",
|
|
|
|
+ null=False,
|
|
|
|
+ blank=False,
|
|
|
|
+ related_name="allocations",
|
|
|
|
+ )
|
|
|
|
+ amount = models.DecimalField(
|
|
|
|
+ max_digits=5, decimal_places=2, null=True, verbose_name="montant"
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
def get_active_payment_and_invoices(member):
|
|
def get_active_payment_and_invoices(member):
|
|
@@ -471,14 +543,18 @@ def get_active_payment_and_invoices(member):
|
|
# Fetch relevant and active payments / invoices
|
|
# Fetch relevant and active payments / invoices
|
|
# and sort then by chronological order : olders first, newers last.
|
|
# and sort then by chronological order : olders first, newers last.
|
|
|
|
|
|
- this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
|
|
|
|
|
|
+ this_member_invoices = [
|
|
|
|
+ i for i in member.invoices.filter(validated=True).order_by("date")
|
|
|
|
+ ]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
|
|
|
|
# TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
|
|
# TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
|
|
# conflict / trouble invoices)
|
|
# conflict / trouble invoices)
|
|
|
|
|
|
- active_payments = [p for p in this_member_payments if p.amount_not_allocated() > 0]
|
|
|
|
- active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0]
|
|
|
|
|
|
+ active_payments = [p for p in this_member_payments if p.amount_not_allocated() > 0]
|
|
|
|
+ active_invoices = [
|
|
|
|
+ p for p in this_member_invoices if p.amount_remaining_to_pay() > 0
|
|
|
|
+ ]
|
|
|
|
|
|
return active_payments, active_invoices
|
|
return active_payments, active_invoices
|
|
|
|
|
|
@@ -493,19 +569,20 @@ def update_accounting_for_member(member):
|
|
|
|
|
|
accounting_log.info("Updating accounting for member {} ...".format(member))
|
|
accounting_log.info("Updating accounting for member {} ...".format(member))
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "Member {} current balance is {} ...".format(member, member.balance))
|
|
|
|
|
|
+ "Member {} current balance is {} ...".format(member, member.balance)
|
|
|
|
+ )
|
|
|
|
|
|
reconcile_invoices_and_payments(member)
|
|
reconcile_invoices_and_payments(member)
|
|
|
|
|
|
- this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
|
|
|
|
|
|
+ this_member_invoices = [
|
|
|
|
+ i for i in member.invoices.filter(validated=True).order_by("date")
|
|
|
|
+ ]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
|
|
|
|
- member.balance = compute_balance(this_member_invoices,
|
|
|
|
- this_member_payments)
|
|
|
|
|
|
+ member.balance = compute_balance(this_member_invoices, this_member_payments)
|
|
member.save()
|
|
member.save()
|
|
|
|
|
|
- accounting_log.info("Member {} new balance is {:f}".format(
|
|
|
|
- member, member.balance))
|
|
|
|
|
|
+ accounting_log.info("Member {} new balance is {:f}".format(member, member.balance))
|
|
|
|
|
|
|
|
|
|
def reconcile_invoices_and_payments(member):
|
|
def reconcile_invoices_and_payments(member):
|
|
@@ -519,17 +596,19 @@ def reconcile_invoices_and_payments(member):
|
|
if active_payments == []:
|
|
if active_payments == []:
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"(No active payment for {}.".format(member)
|
|
"(No active payment for {}.".format(member)
|
|
- + " No invoice/payment reconciliation needed.).")
|
|
|
|
|
|
+ + " No invoice/payment reconciliation needed.)."
|
|
|
|
+ )
|
|
return
|
|
return
|
|
elif active_invoices == []:
|
|
elif active_invoices == []:
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "(No active invoice for {}. No invoice/payment ".format(member) +
|
|
|
|
- "reconciliation needed.).")
|
|
|
|
|
|
+ "(No active invoice for {}. No invoice/payment ".format(member)
|
|
|
|
+ + "reconciliation needed.)."
|
|
|
|
+ )
|
|
return
|
|
return
|
|
|
|
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "Initiating reconciliation between invoice and payments for {}".format(
|
|
|
|
- member))
|
|
|
|
|
|
+ "Initiating reconciliation between invoice and payments for {}".format(member)
|
|
|
|
+ )
|
|
|
|
|
|
while active_payments != [] and active_invoices != []:
|
|
while active_payments != [] and active_invoices != []:
|
|
|
|
|
|
@@ -542,8 +621,8 @@ def reconcile_invoices_and_payments(member):
|
|
assert p.invoice in active_invoices
|
|
assert p.invoice in active_invoices
|
|
i = p.invoice
|
|
i = p.invoice
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
- "Payment is to be allocated specifically to invoice {}".format(
|
|
|
|
- i.number))
|
|
|
|
|
|
+ "Payment is to be allocated specifically to invoice {}".format(i.number)
|
|
|
|
+ )
|
|
else:
|
|
else:
|
|
i = active_invoices[0]
|
|
i = active_invoices[0]
|
|
|
|
|
|
@@ -563,12 +642,12 @@ def reconcile_invoices_and_payments(member):
|
|
|
|
|
|
def compute_balance(invoices, payments):
|
|
def compute_balance(invoices, payments):
|
|
|
|
|
|
- active_payments = [p for p in payments if p.amount_not_allocated() > 0]
|
|
|
|
|
|
+ active_payments = [p for p in payments if p.amount_not_allocated() > 0]
|
|
active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
|
|
active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
|
|
|
|
|
|
s = 0
|
|
s = 0
|
|
s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
|
|
s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
|
|
- s += sum([p.amount_not_allocated() for p in active_payments])
|
|
|
|
|
|
+ s += sum([p.amount_not_allocated() for p in active_payments])
|
|
|
|
|
|
return s
|
|
return s
|
|
|
|
|
|
@@ -578,19 +657,34 @@ def compute_balance(invoices, payments):
|
|
def payment_changed(sender, instance, created, **kwargs):
|
|
def payment_changed(sender, instance, created, **kwargs):
|
|
|
|
|
|
if created:
|
|
if created:
|
|
- accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
|
|
|
|
- % (instance.pk, instance.date, instance.member,
|
|
|
|
- instance.amount, instance.label))
|
|
|
|
|
|
+ accounting_log.info(
|
|
|
|
+ "Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
|
|
|
|
+ % (
|
|
|
|
+ instance.pk,
|
|
|
|
+ instance.date,
|
|
|
|
+ instance.member,
|
|
|
|
+ instance.amount,
|
|
|
|
+ instance.label,
|
|
|
|
+ )
|
|
|
|
+ )
|
|
else:
|
|
else:
|
|
- accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
|
|
|
|
- % (instance.pk, instance.date, instance.member,
|
|
|
|
- instance.amount, instance.label,
|
|
|
|
- instance.amount_already_allocated()))
|
|
|
|
|
|
+ accounting_log.info(
|
|
|
|
+ "Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
|
|
|
|
+ % (
|
|
|
|
+ instance.pk,
|
|
|
|
+ instance.date,
|
|
|
|
+ instance.member,
|
|
|
|
+ instance.amount,
|
|
|
|
+ instance.label,
|
|
|
|
+ instance.amount_already_allocated(),
|
|
|
|
+ )
|
|
|
|
+ )
|
|
|
|
|
|
# If this payment is related to a member, update the accounting for
|
|
# If this payment is related to a member, update the accounting for
|
|
# this member
|
|
# this member
|
|
- if (created or instance.amount_not_allocated() != 0) \
|
|
|
|
- and (instance.member is not None):
|
|
|
|
|
|
+ if (created or instance.amount_not_allocated() != 0) and (
|
|
|
|
+ instance.member is not None
|
|
|
|
+ ):
|
|
update_accounting_for_member(instance.member)
|
|
update_accounting_for_member(instance.member)
|
|
|
|
|
|
|
|
|
|
@@ -601,17 +695,26 @@ def invoice_changed(sender, instance, created, **kwargs):
|
|
if created:
|
|
if created:
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Creating draft invoice DRAFT-{} (Member: {}).".format(
|
|
"Creating draft invoice DRAFT-{} (Member: {}).".format(
|
|
- instance.pk, instance.member))
|
|
|
|
|
|
+ instance.pk, instance.member
|
|
|
|
+ )
|
|
|
|
+ )
|
|
else:
|
|
else:
|
|
if not instance.validated:
|
|
if not instance.validated:
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Updating draft invoice DRAFT-{} (Member: {}).".format(
|
|
"Updating draft invoice DRAFT-{} (Member: {}).".format(
|
|
- instance.number, instance.member))
|
|
|
|
|
|
+ instance.number, instance.member
|
|
|
|
+ )
|
|
|
|
+ )
|
|
else:
|
|
else:
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
|
|
"Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
|
|
- instance.number, instance.member,
|
|
|
|
- instance.amount(), instance.amount_paid()))
|
|
|
|
|
|
+ instance.number,
|
|
|
|
+ instance.member,
|
|
|
|
+ instance.amount(),
|
|
|
|
+ instance.amount_paid(),
|
|
|
|
+ )
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
|
|
@receiver(post_delete, sender=PaymentAllocation)
|
|
@receiver(post_delete, sender=PaymentAllocation)
|
|
def paymentallocation_deleted(sender, instance, **kwargs):
|
|
def paymentallocation_deleted(sender, instance, **kwargs):
|
|
@@ -630,18 +733,19 @@ def payment_deleted(sender, instance, **kwargs):
|
|
|
|
|
|
accounting_log.info(
|
|
accounting_log.info(
|
|
"Deleted payment {} (Date: {}, Member: {}, Amount: {}, Label: {}).".format(
|
|
"Deleted payment {} (Date: {}, Member: {}, Amount: {}, Label: {}).".format(
|
|
- instance.pk, instance.date, instance.member, instance.amount, instance.label))
|
|
|
|
|
|
+ instance.pk, instance.date, instance.member, instance.amount, instance.label
|
|
|
|
+ )
|
|
|
|
+ )
|
|
|
|
|
|
member = instance.member
|
|
member = instance.member
|
|
|
|
|
|
if member is None:
|
|
if member is None:
|
|
return
|
|
return
|
|
|
|
|
|
- this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
|
|
|
|
|
|
+ this_member_invoices = [
|
|
|
|
+ i for i in member.invoices.filter(validated=True).order_by("date")
|
|
|
|
+ ]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
this_member_payments = [p for p in member.payments.order_by("date")]
|
|
|
|
|
|
- member.balance = compute_balance(this_member_invoices,
|
|
|
|
- this_member_payments)
|
|
|
|
|
|
+ member.balance = compute_balance(this_member_invoices, this_member_payments)
|
|
member.save()
|
|
member.save()
|
|
-
|
|
|
|
-
|
|
|