forked from CGM_Public/pretix_original
* Install django-scopes * Fix tests.api * Update tasks and cronjobs * Fix remaining tests * Remove unused import * Fix tests after rebase * Disable scopes for get_Events_with_any_permission * Disable scopes for a management command
267 lines
12 KiB
Python
267 lines
12 KiB
Python
import string
|
|
from decimal import Decimal
|
|
|
|
from django.db import DatabaseError, models, transaction
|
|
from django.db.models import Max
|
|
from django.db.models.functions import Cast
|
|
from django.utils import timezone
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.functional import cached_property
|
|
from django.utils.translation import pgettext
|
|
from django_countries.fields import CountryField
|
|
from django_scopes import ScopedManager
|
|
|
|
|
|
def invoice_filename(instance, filename: str) -> str:
|
|
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
|
return 'invoices/{org}/{ev}/{no}-{code}-{secret}.{ext}'.format(
|
|
org=instance.event.organizer.slug, ev=instance.event.slug,
|
|
no=instance.number, code=instance.order.code, secret=secret,
|
|
ext=filename.split('.')[-1]
|
|
)
|
|
|
|
|
|
def today():
|
|
return timezone.now().date()
|
|
|
|
|
|
class Invoice(models.Model):
|
|
"""
|
|
Represents an invoice that is issued because of an order. Because invoices are legally required
|
|
not to change, this object duplicates a lot of data (e.g. the invoice address).
|
|
|
|
:param order: The associated order
|
|
:type order: Order
|
|
:param event: The event this belongs to (for convenience)
|
|
:type event: Event
|
|
:param organizer: The organizer this belongs to (redundant, for enforcing uniqueness)
|
|
:type organizer: Organizer
|
|
:param invoice_no: The human-readable, event-unique invoice number
|
|
:type invoice_no: int
|
|
:param is_cancellation: Whether or not this is a cancellation instead of an invoice
|
|
:type is_cancellation: bool
|
|
:param refers: A link to another invoice this invoice refers to, e.g. the canceled invoice in a cancellation
|
|
:type refers: Invoice
|
|
:param invoice_from: The sender address
|
|
:type invoice_from: str
|
|
:param invoice_to: The receiver address
|
|
:type invoice_to: str
|
|
:param full_invoice_no: The full invoice number (for performance reasons only)
|
|
:type full_invoice_no: str
|
|
:param date: The invoice date
|
|
:type date: date
|
|
:param locale: The locale in which the invoice should be printed
|
|
:type locale: str
|
|
:param introductory_text: Introductory text for the invoice, e.g. for a greeting
|
|
:type introductory_text: str
|
|
:param additional_text: Additional text for the invoice
|
|
:type additional_text: str
|
|
:param payment_provider_text: A payment provider specific text
|
|
:type payment_provider_text: str
|
|
:param footer_text: A footer text, displayed smaller and centered on every page
|
|
:type footer_text: str
|
|
:param foreign_currency_display: A different currency that taxes should also be displayed in.
|
|
:type foreign_currency_display: str
|
|
:param foreign_currency_rate: The rate of a foreign currency that the taxes should be displayed in.
|
|
:type foreign_currency_rate: Decimal
|
|
:param foreign_currency_rate_date: The date of the foreign currency exchange rates.
|
|
:type foreign_currency_rate_date: date
|
|
:param file: The filename of the rendered invoice
|
|
:type file: File
|
|
"""
|
|
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
|
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
|
|
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
|
|
prefix = models.CharField(max_length=160, db_index=True)
|
|
invoice_no = models.CharField(max_length=19, db_index=True)
|
|
full_invoice_no = models.CharField(max_length=190, db_index=True)
|
|
is_cancellation = models.BooleanField(default=False)
|
|
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
|
|
invoice_from = models.TextField()
|
|
invoice_from_name = models.CharField(max_length=190, null=True)
|
|
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
|
invoice_from_city = models.CharField(max_length=190, null=True)
|
|
invoice_from_country = CountryField(null=True)
|
|
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
|
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
|
invoice_to = models.TextField()
|
|
invoice_to_company = models.TextField(null=True)
|
|
invoice_to_name = models.TextField(null=True)
|
|
invoice_to_street = models.TextField(null=True)
|
|
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
|
invoice_to_city = models.TextField(null=True)
|
|
invoice_to_country = CountryField(null=True)
|
|
invoice_to_vat_id = models.TextField(null=True)
|
|
invoice_to_beneficiary = models.TextField(null=True)
|
|
date = models.DateField(default=today)
|
|
locale = models.CharField(max_length=50, default='en')
|
|
introductory_text = models.TextField(blank=True)
|
|
additional_text = models.TextField(blank=True)
|
|
reverse_charge = models.BooleanField(default=False)
|
|
payment_provider_text = models.TextField(blank=True)
|
|
footer_text = models.TextField(blank=True)
|
|
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
|
|
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
|
|
foreign_currency_rate_date = models.DateField(null=True, blank=True)
|
|
shredded = models.BooleanField(default=False)
|
|
|
|
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
|
|
internal_reference = models.TextField(blank=True)
|
|
|
|
objects = ScopedManager(organizer='event__organizer')
|
|
|
|
@staticmethod
|
|
def _to_numeric_invoice_number(number):
|
|
return '{:05d}'.format(int(number))
|
|
|
|
@property
|
|
def full_invoice_from(self):
|
|
parts = [
|
|
self.invoice_from_name,
|
|
self.invoice_from,
|
|
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
|
self.invoice_from_country.name if self.invoice_from_country else "",
|
|
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
|
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
|
|
]
|
|
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
|
|
|
@property
|
|
def address_invoice_from(self):
|
|
parts = [
|
|
self.invoice_from_name,
|
|
self.invoice_from,
|
|
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
|
self.invoice_from_country.name if self.invoice_from_country else "",
|
|
]
|
|
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
|
|
|
@property
|
|
def address_invoice_to(self):
|
|
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
|
|
return self.invoice_to
|
|
parts = [
|
|
self.invoice_to_company,
|
|
self.invoice_to_name,
|
|
self.invoice_to_street,
|
|
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""),
|
|
self.invoice_to_country.name if self.invoice_to_country else "",
|
|
]
|
|
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
|
|
|
def _get_numeric_invoice_number(self):
|
|
numeric_invoices = Invoice.objects.filter(
|
|
event__organizer=self.event.organizer,
|
|
prefix=self.prefix,
|
|
).exclude(invoice_no__contains='-').annotate(
|
|
numeric_number=Cast('invoice_no', models.IntegerField())
|
|
).aggregate(
|
|
max=Max('numeric_number')
|
|
)['max'] or 0
|
|
return self._to_numeric_invoice_number(numeric_invoices + 1)
|
|
|
|
def _get_invoice_number_from_order(self):
|
|
return '{order}-{count}'.format(
|
|
order=self.order.code,
|
|
count=Invoice.objects.filter(event=self.event, order=self.order).count() + 1,
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.order:
|
|
raise ValueError('Every invoice needs to be connected to an order')
|
|
if not self.event:
|
|
self.event = self.order.event
|
|
if not self.organizer:
|
|
self.organizer = self.order.event.organizer
|
|
if not self.prefix:
|
|
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
|
if not self.invoice_no:
|
|
if self.order.testmode:
|
|
self.prefix += 'TEST-'
|
|
for i in range(10):
|
|
if self.event.settings.get('invoice_numbers_consecutive'):
|
|
self.invoice_no = self._get_numeric_invoice_number()
|
|
else:
|
|
self.invoice_no = self._get_invoice_number_from_order()
|
|
try:
|
|
with transaction.atomic():
|
|
return super().save(*args, **kwargs)
|
|
except DatabaseError:
|
|
# Suppress duplicate key errors and try again
|
|
if i == 9:
|
|
raise
|
|
|
|
self.full_invoice_no = self.prefix + self.invoice_no
|
|
return super().save(*args, **kwargs)
|
|
|
|
def delete(self, *args, **kwargs):
|
|
"""
|
|
Deleting an Invoice would allow for the creation of another Invoice object
|
|
with the same invoice_no as the deleted one. For various reasons, invoice_no
|
|
should be reliably unique for an event.
|
|
"""
|
|
raise Exception('Invoices cannot be deleted, to guarantee uniqueness of Invoice.invoice_no in any event.')
|
|
|
|
@property
|
|
def number(self):
|
|
"""
|
|
Returns the invoice number in a human-readable string with the event slug prepended.
|
|
"""
|
|
return '{prefix}{code}'.format(
|
|
prefix=self.prefix,
|
|
code=self.invoice_no
|
|
)
|
|
|
|
@cached_property
|
|
def canceled(self):
|
|
return self.refered.filter(is_cancellation=True).exists()
|
|
|
|
class Meta:
|
|
unique_together = ('organizer', 'prefix', 'invoice_no')
|
|
ordering = ('date', 'invoice_no',)
|
|
|
|
def __repr__(self):
|
|
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
|
|
|
|
|
|
class InvoiceLine(models.Model):
|
|
"""
|
|
One position listed on an Invoice.
|
|
|
|
:param invoice: The invoice this belongs to
|
|
:type invoice: Invoice
|
|
:param description: The item description
|
|
:type description: str
|
|
:param gross_value: The gross value
|
|
:type gross_value: decimal.Decimal
|
|
:param tax_value: The included tax (as an absolute value)
|
|
:type tax_value: decimal.Decimal
|
|
:param tax_rate: The applied tax rate in percent
|
|
:type tax_rate: decimal.Decimal
|
|
:param tax_name: The name of the applied tax rate
|
|
:type tax_name: str
|
|
:param subevent: The subevent this line refers to
|
|
:type subevent: SubEvent
|
|
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
|
:type event_date_from: datetime
|
|
"""
|
|
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
|
position = models.PositiveIntegerField(default=0)
|
|
description = models.TextField()
|
|
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
|
|
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
|
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
|
tax_name = models.CharField(max_length=190)
|
|
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
|
event_date_from = models.DateTimeField(null=True)
|
|
|
|
@property
|
|
def net_value(self):
|
|
return self.gross_value - self.tax_value
|
|
|
|
class Meta:
|
|
ordering = ('position', 'pk')
|
|
|
|
def __str__(self):
|
|
return 'Line {} of invoice {}'.format(self.position, self.invoice)
|