diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index b98b6f1285..bc5723b4ca 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -26,6 +26,8 @@ invoice_from_country string Sender address: invoice_from_tax_id string Sender address: Local Tax ID invoice_from_vat_id string Sender address: EU VAT ID invoice_to string Full recipient address +invoice_to_is_business boolean Recipient address: Business vs individual (``null`` for + invoices created before pretix 2025.6). invoice_to_company string Recipient address: Company name invoice_to_name string Recipient address: Person name invoice_to_street string Recipient address: Address lines @@ -35,6 +37,7 @@ invoice_to_state string Recipient addre invoice_to_country string Recipient address: Country code invoice_to_vat_id string Recipient address: EU VAT ID invoice_to_beneficiary string Invoice beneficiary +invoice_to_transmission_info object Additional transmission info (see :ref:`rest-transmission-types`) custom_field string Custom invoice address field date date Invoice date refers string Invoice number of an invoice this invoice refers to @@ -110,6 +113,12 @@ foreign_currency_rate decimal (string) If ``foreign_cu foreign_currency_rate_date date If ``foreign_currency_rate`` is set, this signifies the date at which the currency rate was obtained. internal_reference string Customer's reference to be printed on the invoice. +transmission_type string Requested transmission channel (see :ref:`rest-transmission-types`) +transmission_provider string Selected transmission provider (depends on installed + plugins). ``null`` if not yet chosen. +transmission_status string Transmission status, one of ``unknown`` (pre-2025.6), + ``pending``, ``inflight``, ``failed``, and ``completed``. +transmission_date datetime Time of last change in transmission status (may be ``null``). ===================================== ========================== ======================================================= @@ -121,6 +130,76 @@ internal_reference string Customer's refe The ``tax_code`` attribute has been added. +.. versionchanged:: 2025.6 + + The attributes ``invoice_to_is_business``, ``invoice_to_transmission_info``, ``transmission_type``, + ``transmission_provider``, ``transmission_status``, and ``transmission_date`` have been added. + + +.. _`rest-transmission-types`: + +Transmission types +------------------ + +pretix supports multiple ways to transmit an invoice from the organizer to the invoice recipient. +For each transmission type, different fields are supported in the ``transmission_info`` object of the +invoice address. Currently, pretix supports the following transmission types: + +Email +""""" + +The identifier ``"email"`` represents the transmission of PDF invoices through email. +This is the default transmission type in pretix and has some special behavior for backwards compatibility. +Transmission is always executed through the provider ``"email_pdf"``. +The ``transmission_info`` object may contain the following properties: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +transmission_email_address string Optional. An email address other than the order address + that the invoice should be sent to. + Business customers only. +===================================== ========================== ======================================================= + +PEPPOL +"""""" + +The identifier ``"peppol"`` represents the transmission of XML invoices through the `PEPPOL`_ network. +This is only available for business addresses. +This is not supported by pretix out of the box and requires the use of a suitable plugin. +The ``transmission_info`` object may contain the following properties: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +transmission_peppol_participant_id string Required. The PEPPOL participant ID of the recipient. +===================================== ========================== ======================================================= + +Italian Exchange System +""""""""""""""""""""""" + +The identifier ``"it_sdi"`` represents the transmission of XML invoices through the `Sistema di Interscambio`_ network used in Italy. +This is only available for addresses with country ``"IT"``. +This is not supported by pretix out of the box and requires the use of a suitable plugin. +The ``transmission_info`` object may contain the following properties: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +transmission_it_sdi_codice_fiscale string Required for non-business address. Fiscal code of the + recipient. +transmission_it_sdi_pec string Required for business addresses. Address for certified + electronic mail. +transmission_it_sdi_recipient_code string Required for businesses. SdI recipient code. +===================================== ========================== ======================================================= + +If this type is selected, ``vat_id`` is required for business addresses. List of all invoices -------------------- @@ -164,6 +243,7 @@ List of all invoices "invoice_from_vat_id":"", "invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789", "invoice_to_company": "Sample company", + "invoice_to_is_business": true, "invoice_to_name": "John Doe", "invoice_to_street": "Test street 12", "invoice_to_zipcode": "12345", @@ -172,6 +252,7 @@ List of all invoices "invoice_to_country": "TE", "invoice_to_vat_id": "EU123456789", "invoice_to_beneficiary": "", + "invoice_to_transmission_info": {}, "custom_field": null, "date": "2017-12-01", "refers": null, @@ -204,7 +285,11 @@ List of all invoices ], "foreign_currency_display": "PLN", "foreign_currency_rate": "4.2408", - "foreign_currency_rate_date": "2017-07-24" + "foreign_currency_rate_date": "2017-07-24", + "transmission_type": "email", + "transmission_provider": "email_pdf", + "transmission_status": "completed", + "transmission_date": "2017-07-24T10:00:00Z" } ] } @@ -304,6 +389,7 @@ Fetching individual invoices "invoice_from_vat_id":"", "invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789", "invoice_to_company": "Sample company", + "invoice_to_is_business": true, "invoice_to_name": "John Doe", "invoice_to_street": "Test street 12", "invoice_to_zipcode": "12345", @@ -312,6 +398,7 @@ Fetching individual invoices "invoice_to_country": "TE", "invoice_to_vat_id": "EU123456789", "invoice_to_beneficiary": "", + "invoice_to_transmission_info": {}, "custom_field": null, "date": "2017-12-01", "refers": null, @@ -344,7 +431,11 @@ Fetching individual invoices ], "foreign_currency_display": "PLN", "foreign_currency_rate": "4.2408", - "foreign_currency_rate_date": "2017-07-24" + "foreign_currency_rate_date": "2017-07-24", + "transmission_type": "email", + "transmission_provider": "email_pdf", + "transmission_status": "completed", + "transmission_date": "2017-07-24T10:00:00Z" } :param organizer: The ``slug`` field of the organizer to fetch @@ -449,3 +540,70 @@ Invoices cannot be edited directly, but the following actions can be triggered: :statuscode 400: The invoice has already been canceled :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + + +Transmitting invoices +--------------------- + +Invoices are transmitted automatically when created during order creation or payment receipt, +but in other cases transmission may need to be triggered manually. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/transmit/ + + Transmits the invoice to the recipient, but only if it is in ``pending`` state. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/transmit/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + Content-Type: application/pdf + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param number: The ``number`` field of the invoice to transmit + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted + :statuscode 409: The invoice is currently in transmission + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/retransmit/ + + Transmits the invoice to the recipient even if transmission was already attempted previously. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/retransmit/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + Content-Type: application/pdf + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param number: The ``number`` field of the invoice to transmit + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted + :statuscode 409: The invoice is currently in transmission + + +.. _PEPPOL: https://en.wikipedia.org/wiki/PEPPOL +.. _Sistema di Interscambio: https://it.wikipedia.org/wiki/Fattura_elettronica_in_Italia \ No newline at end of file diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 4c4c57a1b4..e1c3c30a60 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -65,11 +65,16 @@ invoice_address object Invoice address ├ state string Customer state (ISO 3166-2 code). Only supported in AU, BR, CA, CN, MY, MX, and US. ├ internal_reference string Customer's internal reference to be printed on the invoice + ├ custom_field string Custom invoice address field ├ vat_id string Customer VAT ID -└ vat_id_validated string ``true``, if the VAT ID has been validated against the +├ vat_id_validated string ``true``, if the VAT ID has been validated against the EU VAT service and validation was successful. This only happens in rare cases. +├ transmission_type string Transmission channel for invoice (see also :ref:`rest-transmission-types`). + Defaults to ``email``. +└ transmission_info object Transmission-channel specific information (or ``null``). + See also :ref:`rest-transmission-types`. positions list of objects List of order positions (see below). By default, only non-canceled positions are included. fees list of objects List of fees included in the order total. By default, only @@ -142,6 +147,10 @@ plugin_data object Additional data The ``plugin_data`` attribute has been added. +.. versionchanged:: 2025.6 + + The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added. + .. _order-position-resource: Order position resource @@ -368,7 +377,9 @@ List of all orders "state": "", "internal_reference": "", "vat_id": "EU123456789", - "vat_id_validated": false + "vat_id_validated": false, + "transmission_type": "email", + "transmission_info": {} }, "positions": [ { @@ -610,7 +621,9 @@ Fetching individual orders "state": "", "internal_reference": "", "vat_id": "EU123456789", - "vat_id_validated": false + "vat_id_validated": false, + "transmission_type": "email", + "transmission_info": {} }, "positions": [ { @@ -1017,6 +1030,8 @@ Creating orders * ``vat_id_validated`` (optional) – If you need support for reverse charge (rarely the case), you need to check yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will trigger reverse charge taxation. Don't forget to set ``is_business`` as well! + * ``transmission_type`` (optional, defaults to ``email``) + * ``transmission_info`` (optional, see also :ref:`rest-transmission-types`) * ``positions`` diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index e891df35d1..3d18ff9220 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -13,6 +13,7 @@ Contents: email placeholder invoice + invoicetransmission shredder import customview diff --git a/doc/development/api/invoicetransmission.rst b/doc/development/api/invoicetransmission.rst new file mode 100644 index 0000000000..e897dcbf82 --- /dev/null +++ b/doc/development/api/invoicetransmission.rst @@ -0,0 +1,65 @@ +.. highlight:: python + :linenothreshold: 5 + +Writing an invoice transmission plugin +====================================== + +An invoice transmission provider transports an invoice from the sender to the recipient. +There are pre-defined types of invoice transmission in pretix, currently ``"email"``, ``"peppol"``, and ``"it_sdi"``. +You can find more information about them at :ref:`rest-transmission-types`. + +New transmission types can not be added by plugins but need to be added to pretix itself. +However, plugins can provide implementations for the actual transmission. +Please read :ref:`Creating a plugin ` first, if you haven't already. + +Output registration +------------------- + +New invoice transmission providers can be registered through the :ref:`registry ` mechanism + +.. code-block:: python + + from pretix.base.invoicing.transmission import transmission_providers, TransmissionProvider + + @transmission_providers.new() + class SdiTransmissionProvider(TransmissionProvider): + identifier = "fatturapa_providerabc" + type = "it_sdi" + verbose_name = _("FatturaPA through provider ABC") + ... + + +The provider class +------------------ + +.. class:: pretix.base.invoicing.transmission.TransmissionProvider + + .. autoattribute:: identifier + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: type + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: verbose_name + + This is an abstract attribute, you **must** override this! + + .. autoattribute:: priority + + .. autoattribute:: testmode_supported + + .. automethod:: is_ready + + This is an abstract method, you **must** override this! + + .. automethod:: is_available + + This is an abstract method, you **must** override this! + + .. automethod:: transmit + + This is an abstract method, you **must** override this! + + .. automethod:: settings_url diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index 52827b99a8..785e607a01 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -19,45 +19,16 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from django import forms from django.conf import settings from django.http import QueryDict from pytz import common_timezones from rest_framework import serializers from rest_framework.exceptions import ValidationError +from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport -from pretix.base.timeframes import DateFrameField, SerializerDateFrameField - - -class FormFieldWrapperField(serializers.Field): - def __init__(self, *args, **kwargs): - self.form_field = kwargs.pop('form_field') - super().__init__(*args, **kwargs) - - def to_representation(self, value): - return self.form_field.widget.format_value(value) - - def to_internal_value(self, data): - d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name') - d = self.form_field.clean(d) - return d - - -simple_mappings = ( - (forms.DateField, serializers.DateField, ()), - (forms.TimeField, serializers.TimeField, ()), - (forms.SplitDateTimeField, serializers.DateTimeField, ()), - (forms.DateTimeField, serializers.DateTimeField, ()), - (forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')), - (forms.FloatField, serializers.FloatField, ()), - (forms.IntegerField, serializers.IntegerField, ()), - (forms.EmailField, serializers.EmailField, ()), - (forms.UUIDField, serializers.UUIDField, ()), - (forms.URLField, serializers.URLField, ()), - (forms.BooleanField, serializers.BooleanField, ()), -) +from pretix.base.timeframes import SerializerDateFrameField class SerializerDescriptionField(serializers.Field): @@ -81,13 +52,6 @@ class ExporterSerializer(serializers.Serializer): input_parameters = SerializerDescriptionField(source='_serializer') -class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): - def to_representation(self, value): - if isinstance(value, int): - return value - return super().to_representation(value) - - class JobRunSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): ex = kwargs.pop('exporter') @@ -102,59 +66,7 @@ class JobRunSerializer(serializers.Serializer): many=True ) for k, v in ex.export_form_fields.items(): - for m_from, m_to, m_kwargs in simple_mappings: - if isinstance(v, m_from): - self.fields[k] = m_to( - required=v.required, - allow_null=not v.required, - validators=v.validators, - **{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs} - ) - break - - if isinstance(v, forms.NullBooleanField): - self.fields[k] = serializers.BooleanField( - required=v.required, - allow_null=True, - validators=v.validators, - ) - if isinstance(v, forms.ModelMultipleChoiceField): - self.fields[k] = PrimaryKeyRelatedField( - queryset=v.queryset, - required=v.required, - allow_empty=not v.required, - validators=v.validators, - many=True - ) - elif isinstance(v, forms.ModelChoiceField): - self.fields[k] = PrimaryKeyRelatedField( - queryset=v.queryset, - required=v.required, - allow_null=not v.required, - validators=v.validators, - ) - elif isinstance(v, forms.MultipleChoiceField): - self.fields[k] = serializers.MultipleChoiceField( - choices=v.choices, - required=v.required, - allow_empty=not v.required, - validators=v.validators, - ) - elif isinstance(v, forms.ChoiceField): - self.fields[k] = serializers.ChoiceField( - choices=v.choices, - required=v.required, - allow_null=not v.required, - validators=v.validators, - ) - elif isinstance(v, DateFrameField): - self.fields[k] = SerializerDateFrameField( - required=v.required, - allow_null=not v.required, - validators=v.validators, - ) - else: - self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required) + self.fields[k] = form_field_to_serializer_field(v) def to_internal_value(self, data): if isinstance(data, QueryDict): diff --git a/src/pretix/api/serializers/forms.py b/src/pretix/api/serializers/forms.py new file mode 100644 index 0000000000..a7817471c3 --- /dev/null +++ b/src/pretix/api/serializers/forms.py @@ -0,0 +1,115 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django import forms +from rest_framework import serializers + +from pretix.base.timeframes import DateFrameField, SerializerDateFrameField + +simple_mappings = ( + (forms.DateField, serializers.DateField, ()), + (forms.TimeField, serializers.TimeField, ()), + (forms.SplitDateTimeField, serializers.DateTimeField, ()), + (forms.DateTimeField, serializers.DateTimeField, ()), + (forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')), + (forms.FloatField, serializers.FloatField, ()), + (forms.IntegerField, serializers.IntegerField, ()), + (forms.EmailField, serializers.EmailField, ()), + (forms.UUIDField, serializers.UUIDField, ()), + (forms.URLField, serializers.URLField, ()), + (forms.BooleanField, serializers.BooleanField, ()), +) + + +class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + def to_representation(self, value): + if isinstance(value, int): + return value + return super().to_representation(value) + + +class FormFieldWrapperField(serializers.Field): + def __init__(self, *args, **kwargs): + self.form_field = kwargs.pop('form_field') + super().__init__(*args, **kwargs) + + def to_representation(self, value): + return self.form_field.widget.format_value(value) + + def to_internal_value(self, data): + d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name') + d = self.form_field.clean(d) + return d + + +def form_field_to_serializer_field(field): + for m_from, m_to, m_kwargs in simple_mappings: + if isinstance(field, m_from): + return m_to( + required=field.required, + allow_null=not field.required, + validators=field.validators, + **{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs} + ) + + if isinstance(field, forms.NullBooleanField): + return serializers.BooleanField( + required=field.required, + allow_null=True, + validators=field.validators, + ) + if isinstance(field, forms.ModelMultipleChoiceField): + return PrimaryKeyRelatedField( + queryset=field.queryset, + required=field.required, + allow_empty=not field.required, + validators=field.validators, + many=True + ) + elif isinstance(field, forms.ModelChoiceField): + return PrimaryKeyRelatedField( + queryset=field.queryset, + required=field.required, + allow_null=not field.required, + validators=field.validators, + ) + elif isinstance(field, forms.MultipleChoiceField): + return serializers.MultipleChoiceField( + choices=field.choices, + required=field.required, + allow_empty=not field.required, + validators=field.validators, + ) + elif isinstance(field, forms.ChoiceField): + return serializers.ChoiceField( + choices=field.choices, + required=field.required, + allow_null=not field.required, + validators=field.validators, + ) + elif isinstance(field, DateFrameField): + return SerializerDateFrameField( + required=field.required, + allow_null=not field.required, + validators=field.validators, + ) + else: + return FormFieldWrapperField(form_field=field, required=field.required, allow_null=not field.required) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 50c91c7f66..68c7f7e9fe 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -42,6 +42,7 @@ from rest_framework.reverse import reverse from pretix.api.serializers import CompatibleJSONField from pretix.api.serializers.event import SubEventSerializer +from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.item import ( InlineItemVariationSerializer, ItemSerializer, QuestionSerializer, @@ -49,6 +50,7 @@ from pretix.api.serializers.item import ( from pretix.api.signals import order_api_details, orderposition_api_details from pretix.base.decimal import round_decimal from pretix.base.i18n import language +from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.models import ( CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, QuestionAnswer, @@ -102,6 +104,13 @@ class CountryField(serializers.Field): return str(src) if src else None +class TransmissionInfoSerializer(serializers.Serializer): + def __init__(self, *args, transmission_type, **kwargs): + super().__init__(*args, **kwargs) + for k, v in transmission_type.invoice_address_form_fields.items(): + self.fields[k] = form_field_to_serializer_field(v) + + class InvoiceAddressSerializer(I18nAwareModelSerializer): country = CompatibleCountryField(source='*') name = serializers.CharField(required=False) @@ -109,7 +118,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): class Meta: model = InvoiceAddress fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country', - 'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference') + 'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference', 'transmission_type', + 'transmission_info') read_only_fields = ('last_modified',) def __init__(self, *args, **kwargs): @@ -147,6 +157,48 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): {'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]} ) + if data.get("transmission_type"): + for t in get_transmission_types(): + if data.get("transmission_type") == t.identifier: + if not t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")): + raise ValidationError({ + "transmission_type": "The selected transmission type is not available for this country or address type." + }) + + ts = TransmissionInfoSerializer(transmission_type=t, data=data.get("transmission_info", {})) + try: + ts.is_valid(raise_exception=True) + except ValidationError as e: + raise ValidationError( + {"transmission_info": e.detail} + ) + data["transmission_info"] = ts.validated_data + + required_fields = t.invoice_address_form_fields_required(data.get("country"), data.get("is_business")) + for r in required_fields: + if r in self.fields: + if not data.get(r): + raise ValidationError( + {r: "This field is required for the selected type of invoice transmission."} + ) + else: + if not ts.validated_data.get(r): + raise ValidationError( + {"transmission_info": {r: "This field is required for the selected type of invoice transmission."}} + ) + break # do not call else branch of for loop + elif t.exclusive: + if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")): + raise ValidationError({ + "transmission_type": "The transmission type '%s' must be used for this country or address type." % ( + t.identifier, + ) + }) + else: + raise ValidationError( + {"transmission_type": "Unknown transmission type."} + ) + return data @@ -1725,12 +1777,13 @@ class InvoiceSerializer(I18nAwareModelSerializer): model = Invoice fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode', 'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id', - 'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode', - 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary', - 'custom_field', 'date', 'refers', 'locale', + 'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', + 'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', + 'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale', 'introductory_text', 'additional_text', 'payment_provider_text', 'payment_provider_stamp', 'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate', - 'foreign_currency_rate_date', 'internal_reference') + 'foreign_currency_rate_date', 'internal_reference', 'transmission_type', 'transmission_provider', + 'transmission_status', 'transmission_date') class OrderPaymentCreateSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 4efda9ed49..faded5becf 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -88,7 +88,7 @@ from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, - regenerate_invoice, + regenerate_invoice, transmit_invoice, ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( @@ -1891,6 +1891,12 @@ class RetryException(APIException): default_code = 'retry_later' +class CurrentlyInflightException(APIException): + status_code = 409 + default_detail = 'The requested action is already in progress.' + default_code = 'currently_inflight' + + class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = InvoiceSerializer queryset = Invoice.objects.none() @@ -1939,13 +1945,52 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) return resp + @action(detail=True, methods=['POST']) + def transmit(self, request, **kwargs): + invoice = self.get_object() + if invoice.shredded: + raise PermissionDenied('The invoice file is no longer stored on the server.') + + if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING: + raise PermissionDenied('The invoice is not in pending state.') + + transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, False)) + return Response(status=204) + + @action(detail=True, methods=['POST']) + def retransmit(self, request, **kwargs): + invoice = self.get_object() + if invoice.shredded: + raise PermissionDenied('The invoice file is no longer stored on the server.') + + with transaction.atomic(durable=True): + invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice.pk) + + if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT: + raise CurrentlyInflightException() + + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + invoice.order.log_action( + 'pretix.event.order.invoice.retransmitted', + user=self.request.user, + auth=self.request.auth, + data={ + 'invoice': invoice.pk, + 'full_invoice_no': invoice.full_invoice_no, + } + ) + transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True)) + return Response(status=204) + @action(detail=True, methods=['POST']) def regenerate(self, request, **kwargs): inv = self.get_object() if inv.canceled: raise ValidationError('The invoice has already been canceled.') - if not inv.event.settings.invoice_regenerate_allowed: - raise PermissionDenied('Invoices may not be changed after they are created.') + if not inv.regenerate_allowed: + raise PermissionDenied('Invoice may not be regenerated.') elif inv.shredded: raise PermissionDenied('The invoice file is no longer stored on the server.') elif inv.sent_to_organizer: diff --git a/src/pretix/base/apps.py b/src/pretix/base/apps.py index c32df61139..66baca9ab7 100644 --- a/src/pretix/base/apps.py +++ b/src/pretix/base/apps.py @@ -43,7 +43,7 @@ class PretixBaseConfig(AppConfig): from . import exporter # NOQA from . import payment # NOQA from . import exporters # NOQA - from . import invoice # NOQA + from .invoicing import pdf, transmission, email, peppol, national # NOQA from . import notifications # NOQA from . import email # NOQA from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 2a50588ac2..9ec271d082 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -54,7 +54,6 @@ from django.core.validators import ( from django.db.models import QuerySet from django.forms import Select, widgets from django.forms.widgets import FILE_INPUT_CONTRADICTION -from django.urls import reverse from django.utils.formats import date_format from django.utils.html import escape from django.utils.safestring import mark_safe @@ -78,6 +77,7 @@ from pretix.base.forms.widgets import ( from pretix.base.i18n import ( get_babel_locale, get_language_without_region, language, ) +from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models.tax import ask_for_vat_id from pretix.base.services.tax import ( @@ -736,7 +736,7 @@ class BaseQuestionsForm(forms.Form): initial=country, widget=forms.Select(attrs={ 'autocomplete': 'country', - 'data-country-information-url': reverse('js_helpers.states'), + 'data-trigger-address-info': 'on', }), ) c = [('', '---')] @@ -1142,11 +1142,19 @@ class BaseInvoiceAddressForm(forms.ModelForm): if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"): kwargs['initial']['country'] = guess_country_from_request(self.request, self.event) + if kwargs.get('instance'): + kwargs['initial'].update(kwargs['instance'].transmission_info or {}) + kwargs['initial']['transmission_type'] = kwargs['instance'].transmission_type + super().__init__(*args, **kwargs) + # Individuals do not have a company name or VAT ID self.fields["company"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]' self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]' + # The internal reference is a very business-specific field and might confuse non-business users + self.fields["internal_reference"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]' + if not self.ask_vat_id: del self.fields['vat_id'] elif self.validate_vat_id: @@ -1162,8 +1170,20 @@ class BaseInvoiceAddressForm(forms.ModelForm): str(_('If you are registered in Switzerland, you can enter your UID instead.')), ]) + transmission_type_choices = [ + (t.identifier, t.public_name) for t in get_transmission_types() + ] + if not self.address_required or self.all_optional: + transmission_type_choices.insert(0, ("-", _("No invoice requested"))) + self.fields['transmission_type'] = forms.ChoiceField( + label=_('Invoice transmission method'), + choices=transmission_type_choices + ) + self.fields['country'].choices = CachedCountries() - self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states') + self.fields['country'].widget.attrs['data-trigger-address-info'] = 'on' + self.fields['is_business'].widget.attrs['data-trigger-address-info'] = 'on' + self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on' c = [('', '---')] fprefix = self.prefix + '-' if self.prefix else '' @@ -1250,6 +1270,44 @@ class BaseInvoiceAddressForm(forms.ModelForm): else: v.widget.attrs['autocomplete'] = 'section-invoice billing ' + autocomplete + # Add transmission type specific fields + for transmission_type in get_transmission_types(): + for k, f in transmission_type.invoice_address_form_fields.items(): + if ( + transmission_type.identifier == "email" and + k in ("transmission_email_other", "transmission_email_address") and + ( + event.settings.invoice_generate == "False" or + not event.settings.invoice_email_attachment + ) + ): + # This looks like a very unclean hack (and probably really is one), but hear me out: + # With pretix 2025.7, we introduced invoice transmission types and added the "send to another email" + # feature for the email provider. This feature was previously part of the bank transfer payment + # provider and opt-in. With this change, this feature becomes available for all pretix shops, which + # we think is a good thing in the long run as it is an useful feature for every business customer. + # However, there's two scenarios where it might be bad that we add it without opt-in: + # - When the organizer has turned off invoice generation in pretix and is collecting invoice information + # only for other reasons or to later create invoices with a separate software. In this case it + # would be very bad for the user to be able to ask for the invoice to be sent somewhere else, and + # that information then be ignored because the organizer has not updated their process. + # - When the organizer has intentionally turned off invoices being attached to emails, because that + # would somehow be a contradiction. + # Now, the obvious solution would be to make the TransmissionType.invoice_address_form_fields property + # a function that depends on the event as an input. However, I believe this is the wrong approach + # over the long term. As a generalized concept, we DO want invoice address collection to be + # *independent* of event settings, in order to (later) e.g. implement invoice address editing within + # customer accounts. Hence, this hack directly in the form to provide (some) backwards compatibility + # only for the default transmission type "email". + continue + + self.fields[k] = f + f._required = f.required + f.required = False + f.widget.is_required = False + if 'required' in f.widget.attrs: + del f.widget.attrs['required'] + def clean(self): from pretix.base.addressvalidation import \ validate_address # local import to prevent impact on startup time @@ -1277,11 +1335,23 @@ class BaseInvoiceAddressForm(forms.ModelForm): self.instance.name_parts = data.get('name_parts') - if all( - not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts') - ) and name_parts_is_empty(data.get('name_parts', {})): + form_is_empty = all( + not v for k, v in data.items() + if k not in ('is_business', 'country', 'name_parts', 'transmission_type') and not k.startswith("transmission_") + ) and name_parts_is_empty(data.get('name_parts', {})) + + if form_is_empty: # Do not save the country if it is the only field set -- we don't know the user even checked it! self.cleaned_data['country'] = '' + if data.get('transmission_type') == "-": + data['transmission_type'] = 'email' # our actual default for now, we can revisit this later + + else: + if data.get('transmission_type') == "-": + raise ValidationError( + {"transmission_type": _("If you enter an invoice address, you also need to select an invoice " + "transmission method.")} + ) if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: pass @@ -1303,6 +1373,37 @@ class BaseInvoiceAddressForm(forms.ModelForm): else: self.instance.vat_id_validated = False + for transmission_type in get_transmission_types(): + if transmission_type.identifier == data.get("transmission_type"): + if not transmission_type.is_available(self.event, data.get("country"), data.get("is_business")): + raise ValidationError({ + "transmission_type": _("The selected transmission type is not available in your country or for " + "your type of address.") + }) + + required_fields = transmission_type.invoice_address_form_fields_required(data.get("country"), data.get("is_business")) + for r in required_fields: + if r not in self.fields: + logger.info(f"Transmission type {transmission_type.identifier} required field {r} which is not available.") + raise ValidationError( + _("The selected type of invoice transmission requires a field that is currently not " + "available, please reach out to the organizer.") + ) + if not data.get(r): + raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")}) + + self.instance.transmission_type = transmission_type.identifier + self.instance.transmission_info = { + k: data.get(k) for k in transmission_type.invoice_address_form_fields + } + elif transmission_type.exclusive: + if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")): + raise ValidationError({ + "transmission_type": "The transmission type '%s' must be used for this country or address type." % ( + transmission_type.public_name, + ) + }) + class BaseInvoiceNameForm(BaseInvoiceAddressForm): def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 5e91ffc2cd..5efbee0544 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -19,1089 +19,20 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -import logging -import re -import unicodedata -from collections import defaultdict -from decimal import Decimal -from io import BytesIO -from itertools import groupby -from typing import Tuple - -import bleach -import vat_moss.exchange_rates -from bidi import get_display -from django.contrib.staticfiles import finders -from django.db.models import Sum -from django.dispatch import receiver -from django.utils.formats import date_format, localize -from django.utils.translation import ( - get_language, gettext, gettext_lazy, pgettext, -) -from reportlab.lib import colors, pagesizes -from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT -from reportlab.lib.styles import ParagraphStyle, StyleSheet1 -from reportlab.lib.units import mm -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.pdfmetrics import stringWidth -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.pdfgen.canvas import Canvas -from reportlab.platypus import ( - BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate, - PageTemplate, Spacer, Table, TableStyle, +from .invoicing.pdf import ( + BaseInvoiceRenderer, BaseReportlabInvoiceRenderer, ClassicInvoiceRenderer, + Modern1Renderer, Modern1SimplifiedRenderer, NumberedCanvas, + ThumbnailingImageReader, addon_aware_groupby, ) -from pretix.base.decimal import round_decimal -from pretix.base.models import Event, Invoice, Order, OrderPayment -from pretix.base.services.currencies import SOURCE_NAMES -from pretix.base.signals import register_invoice_renderers -from pretix.base.templatetags.money import money_filter -from pretix.helpers.reportlab import ( - FontFallbackParagraph, ThumbnailingImageReader, reshaper, -) -from pretix.presale.style import get_fonts - -logger = logging.getLogger(__name__) - - -def addon_aware_groupby(iterable, key, is_addon): - """ - We use groupby() to visually group identical lines on an invoice. For example, instead of - - Product 1 5.00 EUR - Product 1 5.00 EUR - Product 1 5.00 EUR - Product 2 7.00 EUR - - We want to print - - 3x Product 1 5.00 EUR = 15.00 EUR - Product 2 7.00 EUR - - However, this fails for setups with addon-products since groupby() only groups consecutive - lines with the same identity. So in - - Product 1 5.00 EUR - + Addon 1 2.00 EUR - Product 1 5.00 EUR - + Addon 1 2.00 EUR - Product 1 5.00 EUR - + Addon 2 3.00 EUR - - There is no consecutive repetition of the same entity. This function provides a specialised groupby which - understands the product/addon relationship and packs groups of these addons together if they are, in fact, - identical groups: - - 2x Product 1 5.00 EUR = 10.00 EUR - + 2x Addon 1 2.00 EUR = 4.00 EUR - Product 1 5.00 EUR - + Addon 2 3.00 EUR - """ - packed_groups = [] - - for i in iterable: - if is_addon(i): - packed_groups[-1].append(i) - else: - packed_groups.append([i]) - # Each packed_groups element contains a list with the parent product as first element, and any addon products following - - def _reorder(packed_groups): - # Emit the products as individual products again, reordered by "all parent products, then all addon products" - # within each group. - for _, repeated_groups in groupby(packed_groups, key=lambda g: tuple(key(a) for a in g)): - for repeated_items in zip(*repeated_groups): - yield from repeated_items - - return groupby(_reorder(packed_groups), key) - - -class NumberedCanvas(Canvas): - def __init__(self, *args, **kwargs): - self.font_regular = kwargs.pop('font_regular') - super().__init__(*args, **kwargs) - self._saved_page_states = [] - - def showPage(self): - self._saved_page_states.append(dict(self.__dict__)) - self._startPage() - - def save(self): - num_pages = len(self._saved_page_states) - for state in self._saved_page_states: - self.__dict__.update(state) - self.draw_page_number(num_pages) - Canvas.showPage(self) - Canvas.save(self) - - def draw_page_number(self, page_count): - self.saveState() - self.setFont(self.font_regular, 8) - text = pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,) - try: - text = get_display(reshaper.reshape(text)) - except: - logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) - self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, text) - self.restoreState() - - -class BaseInvoiceRenderer: - """ - This is the base class for all invoice renderers. - """ - - def __init__(self, event: Event): - self.event = event - - def __str__(self): - return self.identifier - - def generate(self, invoice: Invoice) -> Tuple[str, str, str]: - """ - This method should generate the invoice file and return a tuple consisting of a - filename, a file type and file content. The extension will be taken from the filename - which is otherwise ignored. - """ - raise NotImplementedError() - - @property - def verbose_name(self) -> str: - """ - A human-readable name for this renderer. This should be short but - self-explanatory. Good examples include 'German DIN 5008' or 'Italian invoice'. - """ - raise NotImplementedError() # NOQA - - @property - def identifier(self) -> str: - """ - A short and unique identifier for this renderer. - This should only contain lowercase letters and in most - cases will be the same as your package name. - """ - raise NotImplementedError() # NOQA - - -class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): - """ - This is a convenience class to avoid duplicate code when implementing invoice renderers - that are based on reportlab. - """ - pagesize = pagesizes.A4 - left_margin = 25 * mm - right_margin = 20 * mm - top_margin = 20 * mm - bottom_margin = 15 * mm - doc_template_class = BaseDocTemplate - canvas_class = Canvas - font_regular = 'OpenSans' - font_bold = 'OpenSansBd' - - def _init(self): - """ - Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``. - """ - self._register_fonts() - self.stylesheet = self._get_stylesheet() - - def _get_stylesheet(self): - """ - Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles. - """ - stylesheet = StyleSheet1() - stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12)) - stylesheet.add(ParagraphStyle(name='Bold', fontName=self.font_bold, fontSize=10, leading=12)) - stylesheet.add(ParagraphStyle(name='BoldRight', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT)) - stylesheet.add(ParagraphStyle(name='BoldRightNoSplit', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT, - splitLongWords=False)) - stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT)) - stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12, - textColor=colors.white, alignment=TA_CENTER)) - stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal'])) - stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2)) - stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12)) - stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10)) - stylesheet.add(ParagraphStyle(name='FineprintRight', fontName=self.font_regular, fontSize=8, leading=10, alignment=TA_RIGHT)) - return stylesheet - - def _register_fonts(self): - """ - Register fonts with reportlab. By default, this registers the OpenSans font family - """ - pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))) - pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', - italic='OpenSansIt', boldItalic='OpenSansBI') - - for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): - pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) - if family == self.event.settings.invoice_renderer_font: - self.font_regular = family - if 'bold' in styles: - self.font_bold = family + ' B' - if 'italic' in styles: - pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) - if 'bold' in styles: - pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype']))) - if 'bolditalic' in styles: - pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype']))) - - def _normalize(self, text): - # reportlab does not support unicode combination characters - # It's important we do this before we use ArabicReshaper - text = unicodedata.normalize("NFKC", text) - - # reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper - # to resolve all ligatures and python-bidi to switch RTL texts. - try: - text = "
".join(get_display(reshaper.reshape(l)) for l in re.split("
", text)) - except: - logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) - - return text - - def _upper(self, val): - # We uppercase labels, but not in every language - if get_language().startswith('el'): - return val - return val.upper() - - def _on_other_page(self, canvas: Canvas, doc): - """ - Called when a new page is rendered that is *not* the first page. - """ - pass - - def _on_first_page(self, canvas: Canvas, doc): - """ - Called when a new page is rendered that is the first page. - """ - pass - - def _get_story(self, doc): - """ - Called to create the story to be inserted into the main frames. - """ - raise NotImplementedError() - - def _get_first_page_frames(self, doc): - """ - Called to create a list of frames for the first page. - """ - return [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, - id='normal') - ] - - def _get_other_page_frames(self, doc): - """ - Called to create a list of frames for the other pages. - """ - return [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, - id='normal') - ] - - def _build_doc(self, fhandle): - """ - Build a PDF document in a given file handle - """ - self._init() - doc = self.doc_template_class(fhandle, pagesize=self.pagesize, - leftMargin=self.left_margin, rightMargin=self.right_margin, - topMargin=self.top_margin, bottomMargin=self.bottom_margin) - - doc.addPageTemplates([ - PageTemplate( - id='FirstPage', - frames=self._get_first_page_frames(doc), - onPage=self._on_first_page, - pagesize=self.pagesize - ), - PageTemplate( - id='OtherPages', - frames=self._get_other_page_frames(doc), - onPage=self._on_other_page, - pagesize=self.pagesize - ) - ]) - story = self._get_story(doc) - doc.build(story, canvasmaker=self.canvas_class) - return doc - - def generate(self, invoice: Invoice): - self.invoice = invoice - buffer = BytesIO() - self._build_doc(buffer) - buffer.seek(0) - return 'invoice.pdf', 'application/pdf', buffer.read() - - def _clean_text(self, text, tags=None): - return self._normalize(bleach.clean( - text, - tags=set(tags) if tags else set() - ).strip().replace('
', '
').replace('\n', '
\n')) - - -class PaidMarker(Flowable): - def __init__(self, text='paid', color=None, font='OpenSansBd', size=20): - super().__init__() - self.text = text - self.color = color - self.font = font - self.size = size - self._showBoundary = True - - def wrap(self, availwidth, availheight): - # Fake a size, we don't care if we exceed the table - return 10, self.size / 2 - - def draw(self): - self.canv.translate(0, - self.size / 2) - self.canv.rotate(2) - self.canv.setFont(self.font, self.size) - self.canv.setFillColor(self.color) - width = self.canv.stringWidth(self.text, self.font, self.size) - self.canv.drawRightString(0, 0, self.text) - - self.canv.setStrokeColor(self.color) - self.canv.roundRect(-width - self.size / 2, -self.size / 4, width + self.size, self.size + self.size / 4, 3) - - -class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): - identifier = 'classic' - verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') - - def canvas_class(self, *args, **kwargs): - kwargs['font_regular'] = self.font_regular - return NumberedCanvas(*args, **kwargs) - - def _on_other_page(self, canvas: Canvas, doc): - canvas.saveState() - canvas.setFont(self.font_regular, 8) - - for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): - canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) - - canvas.restoreState() - - invoice_to_width = 85 * mm - invoice_to_height = 50 * mm - invoice_to_left = 25 * mm - invoice_to_top = 52 * mm - - def _draw_invoice_to(self, canvas): - p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to), - style=self.stylesheet['Normal']) - p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) - p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) - p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) - - invoice_from_width = 70 * mm - invoice_from_height = 50 * mm - invoice_from_left = 25 * mm - invoice_from_top = 17 * mm - - def _draw_invoice_from(self, canvas): - p = FontFallbackParagraph( - self._clean_text(self.invoice.full_invoice_from), - style=self.stylesheet['InvoiceFrom'] - ) - p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) - p_size = p.wrap(self.invoice_from_width, self.invoice_from_height) - p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top) - - def _draw_invoice_from_label(self, canvas): - textobject = canvas.beginText(25 * mm, (297 - 15) * mm) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice from')))) - canvas.drawText(textobject) - - def _draw_invoice_to_label(self, canvas): - textobject = canvas.beginText(25 * mm, (297 - 50) * mm) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice to')))) - canvas.drawText(textobject) - - logo_width = 25 * mm - logo_height = 25 * mm - logo_left = 95 * mm - logo_top = 13 * mm - logo_anchor = 'n' - - def _draw_logo(self, canvas): - if self.invoice.event.settings.invoice_logo_image: - logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True) - ir = ThumbnailingImageReader(logo_file) - try: - ir.resize(self.logo_width, self.logo_height, 300) - except: - logger.exception("Can not resize image") - pass - try: - # Valid ZUGFeRD invoices must be compliant with PDF/A-3. pretix-zugferd ensures this by passing them - # through ghost script. Unfortunately, if the logo contains transparency, this will still fail. - # I was unable to figure out a way to fix this in GhostScript, so the easy fix is to remove the - # transparency, as our invoices always have a white background anyways. - ir.remove_transparency() - except: - logger.exception("Can not remove transparency from logo") - pass - canvas.drawImage(ir, - self.logo_left, - self.pagesize[1] - self.logo_height - self.logo_top, - width=self.logo_width, height=self.logo_height, - preserveAspectRatio=True, anchor=self.logo_anchor, - mask='auto') - - def _draw_metadata(self, canvas): - textobject = canvas.beginText(125 * mm, (297 - 38) * mm) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Order code')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(self.invoice.order.full_code)) - canvas.drawText(textobject) - - textobject = canvas.beginText(125 * mm, (297 - 50) * mm) - textobject.setFont(self.font_bold, 8) - if self.invoice.is_cancellation: - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation number')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(self.invoice.number)) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(self.invoice.refers.number)) - else: - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice number')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(self.invoice.number)) - textobject.moveCursor(0, 5) - - if self.invoice.is_cancellation: - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation date')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice date')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(date_format(self.invoice.refers.date, "DATE_FORMAT"))) - textobject.moveCursor(0, 5) - else: - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice date')))) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_regular, 10) - textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) - textobject.moveCursor(0, 5) - - canvas.drawText(textobject) - - event_left = 125 * mm - event_top = 17 * mm - event_width = 65 * mm - event_height = 50 * mm - - def _draw_event_label(self, canvas): - textobject = canvas.beginText(125 * mm, (297 - 15) * mm) - textobject.setFont(self.font_bold, 8) - textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event')))) - canvas.drawText(textobject) - - def _draw_event(self, canvas): - def shorten(txt): - txt = str(txt) - txt = bleach.clean(txt, tags=set()).strip() - p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) - p_size = p.wrap(self.event_width, self.event_height) - - while p_size[1] > 2 * self.stylesheet['Normal'].leading: - txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…' - p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) - p_size = p.wrap(self.event_width, self.event_height) - return txt - - if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: - tz = self.invoice.event.timezone - show_end_date = ( - self.invoice.event.settings.show_date_to and - self.invoice.event.date_to and - self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date() - ) - if show_end_date: - p_str = ( - shorten(self.invoice.event.name) + '\n' + - pgettext('invoice', '{from_date}\nuntil {to_date}').format( - from_date=self.invoice.event.get_date_from_display(show_times=False), - to_date=self.invoice.event.get_date_to_display(show_times=False) - ) - ) - else: - p_str = ( - shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False) - ) - else: - p_str = shorten(self.invoice.event.name) - - p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) - p.wrapOn(canvas, self.event_width, self.event_height) - p_size = p.wrap(self.event_width, self.event_height) - p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) - self._draw_event_label(canvas) - - def _draw_footer(self, canvas): - canvas.setFont(self.font_regular, 8) - for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): - canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) - - def _draw_testmode(self, canvas): - if self.invoice.order.testmode: - canvas.saveState() - canvas.setFont(self.font_bold, 30) - canvas.setFillColorRGB(32, 0, 0) - canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, self._normalize(gettext('TEST MODE'))) - canvas.restoreState() - - def _on_first_page(self, canvas: Canvas, doc): - canvas.setCreator('pretix.eu') - canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number)) - - canvas.saveState() - self._draw_footer(canvas) - self._draw_testmode(canvas) - self._draw_invoice_from_label(canvas) - self._draw_invoice_from(canvas) - self._draw_invoice_to_label(canvas) - self._draw_invoice_to(canvas) - self._draw_metadata(canvas) - self._draw_logo(canvas) - self._draw_event(canvas) - canvas.restoreState() - - def _get_first_page_frames(self, doc): - footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm - return [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, - id='normal') - ] - - def _get_other_page_frames(self, doc): - footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm - return [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, - id='normal') - ] - - def _get_intro(self): - story = [] - if self.invoice.custom_field: - story.append(FontFallbackParagraph( - '{}: {}'.format( - self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)), - self._clean_text(self.invoice.custom_field), - ), - self.stylesheet['Normal'] - )) - - if self.invoice.internal_reference: - story.append(FontFallbackParagraph( - self._normalize(pgettext('invoice', 'Customer reference: {reference}').format( - reference=self._clean_text(self.invoice.internal_reference), - )), - self.stylesheet['Normal'] - )) - - if self.invoice.invoice_to_vat_id: - story.append(FontFallbackParagraph( - self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' + - self._clean_text(self.invoice.invoice_to_vat_id), - self.stylesheet['Normal'] - )) - - if self.invoice.invoice_to_beneficiary: - story.append(FontFallbackParagraph( - self._normalize(pgettext('invoice', 'Beneficiary')) + ':
' + - self._clean_text(self.invoice.invoice_to_beneficiary), - self.stylesheet['Normal'] - )) - - if self.invoice.introductory_text: - # While all intro fields are appended without any blank lines; we do want one before the optional intro - # text. However, if there are no prior intro fields, adding an additional spacer will waste space. - if story: - story.append(Spacer(1, 5 * mm)) - - story.append(FontFallbackParagraph( - self._clean_text(self.invoice.introductory_text, tags=['br']), - self.stylesheet['Normal'] - )) - story.append(Spacer(1, 5 * mm)) - - return story - - def _get_story(self, doc): - has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge - - story = [ - NextPageTemplate('FirstPage'), - FontFallbackParagraph( - self._normalize( - pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' - else pgettext('invoice', 'Invoice') - ) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')), - self.stylesheet['Heading1'] - ), - Spacer(1, 5 * mm), - NextPageTemplate('OtherPages'), - ] - story += self._get_intro() - - taxvalue_map = defaultdict(Decimal) - grossvalue_map = defaultdict(Decimal) - - tstyledata = [ - ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('FONTNAME', (0, 0), (-1, -1), self.font_regular), - ('FONTNAME', (0, 0), (-1, 0), self.font_bold), - ('FONTNAME', (0, -1), (-1, -1), self.font_bold), - ('LEFTPADDING', (0, 0), (0, -1), 0), - ('RIGHTPADDING', (-1, 0), (-1, -1), 0), - ] - if has_taxes: - tdata = [( - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']), - )] - else: - tdata = [( - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']), - )] - - def _group_key(line): - return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id, - line.event_date_from, line.event_date_to) - - total = Decimal('0.00') - for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby( - self.invoice.lines.all(), - key=_group_key, - is_addon=lambda l: l.description.startswith(" +"), - ): - lines = list(lines) - if has_taxes: - if len(lines) > 1: - single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format( - net_price=money_filter(net_value, self.invoice.event.currency), - gross_price=money_filter(gross_value, self.invoice.event.currency), - ) - description = description + "\n" + single_price_line - tdata.append(( - FontFallbackParagraph( - self._clean_text(description, tags=['br']), - self.stylesheet['Normal'] - ), - str(len(lines)), - localize(tax_rate) + " %", - FontFallbackParagraph( - money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), - self.stylesheet['NormalRight'] - ), - FontFallbackParagraph( - money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), - self.stylesheet['NormalRight'] - ), - )) - else: - if len(lines) > 1: - single_price_line = pgettext('invoice', 'Single price: {price}').format( - price=money_filter(gross_value, self.invoice.event.currency), - ) - description = description + "\n" + single_price_line - tdata.append(( - FontFallbackParagraph( - self._clean_text(description, tags=['br']), - self.stylesheet['Normal'] - ), - str(len(lines)), - FontFallbackParagraph( - money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), - self.stylesheet['NormalRight'] - ), - )) - taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines) - grossvalue_map[tax_rate, tax_name] += gross_value * len(lines) - total += gross_value * len(lines) - - if has_taxes: - tdata.append([ - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', - money_filter(total, self.invoice.event.currency) - ]) - colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)] - else: - tdata.append([ - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', - money_filter(total, self.invoice.event.currency) - ]) - colwidths = [a * doc.width for a in (.65, .20, .15)] - - if not self.invoice.is_cancellation: - if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING: - pending_sum = self.invoice.order.pending_sum - if pending_sum != total: - tdata.append( - [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] + - (['', '', ''] if has_taxes else ['']) + - [money_filter(pending_sum - total, self.invoice.event.currency)] - ) - tdata.append( - [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] + - (['', '', ''] if has_taxes else ['']) + - [money_filter(pending_sum, self.invoice.event.currency)] - ) - tstyledata += [ - ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), - ] - elif self.invoice.event.settings.invoice_show_payments and self.invoice.order.payments.filter( - state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard' - ).exists(): - giftcard_sum = self.invoice.order.payments.filter( - state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), - provider='giftcard' - ).aggregate( - s=Sum('amount') - )['s'] or Decimal('0.00') - tdata.append( - [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] + - (['', '', ''] if has_taxes else ['']) + - [money_filter(giftcard_sum, self.invoice.event.currency)] - ) - tdata.append( - [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] + - (['', '', ''] if has_taxes else ['']) + - [money_filter(total - giftcard_sum, self.invoice.event.currency)] - ) - tstyledata += [ - ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), - ] - elif self.invoice.payment_provider_stamp: - pm = PaidMarker( - text=self._normalize(self.invoice.payment_provider_stamp), - color=colors.HexColor(self.event.settings.theme_color_success), - font=self.font_bold, - size=16 - ) - tdata[-1][-2] = pm - - table = Table(tdata, colWidths=colwidths, repeatRows=1) - table.setStyle(TableStyle(tstyledata)) - story.append(table) - - story.append(Spacer(1, 10 * mm)) - - if self.invoice.payment_provider_text: - story.append(FontFallbackParagraph( - self._normalize(self.invoice.payment_provider_text), - self.stylesheet['Normal'] - )) - - if self.invoice.payment_provider_text and self.invoice.additional_text: - story.append(Spacer(1, 3 * mm)) - - if self.invoice.additional_text: - story.append(FontFallbackParagraph( - self._clean_text(self.invoice.additional_text, tags=['br']), - self.stylesheet['Normal'] - )) - story.append(Spacer(1, 5 * mm)) - - tstyledata = [ - ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), - ('LEFTPADDING', (0, 0), (0, -1), 0), - ('RIGHTPADDING', (-1, 0), (-1, -1), 0), - ('TOPPADDING', (0, 0), (-1, -1), 1), - ('BOTTOMPADDING', (0, 0), (-1, -1), 1), - ('FONTSIZE', (0, 0), (-1, -1), 8), - ('FONTNAME', (0, 0), (-1, -1), self.font_regular), - ] - thead = [ - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']), - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']), - '' - ] - tdata = [thead] - - for idx, gross in grossvalue_map.items(): - rate, name = idx - if rate == 0 and gross == 0: - continue - tax = taxvalue_map[idx] - tdata.append([ - FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), - money_filter(gross - tax, self.invoice.event.currency), - money_filter(gross, self.invoice.event.currency), - money_filter(tax, self.invoice.event.currency), - '' - ]) - - def fmt(val): - try: - return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) - except ValueError: - return localize(val) + ' ' + self.invoice.foreign_currency_display - - if any(rate != 0 and gross != 0 for (rate, name), gross in grossvalue_map.items()) and has_taxes: - colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)] - table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) - table.setStyle(TableStyle(tstyledata)) - story.append(Spacer(5 * mm, 5 * mm)) - story.append(KeepTogether([ - FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']), - table - ])) - - if self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: - tdata = [thead] - - for idx, gross in grossvalue_map.items(): - rate, name = idx - if rate == 0: - continue - tax = taxvalue_map[idx] - gross = round_decimal(gross * self.invoice.foreign_currency_rate) - tax = round_decimal(tax * self.invoice.foreign_currency_rate) - net = gross - tax - - tdata.append([ - FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), - fmt(net), fmt(gross), fmt(tax), '' - ]) - - table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) - table.setStyle(TableStyle(tstyledata)) - - story.append(KeepTogether([ - Spacer(1, height=2 * mm), - FontFallbackParagraph( - self._normalize(pgettext( - 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' - '{date}, this corresponds to:' - ).format(rate=localize(self.invoice.foreign_currency_rate), - authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), - date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))), - self.stylesheet['Fineprint'] - ), - Spacer(1, height=3 * mm), - table - ])) - elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: - foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) - story.append(Spacer(1, 5 * mm)) - story.append(FontFallbackParagraph(self._normalize( - pgettext( - 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' - '{date}, the invoice total corresponds to {total}.' - ).format(rate=localize(self.invoice.foreign_currency_rate), - date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), - authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), - total=fmt(foreign_total))), - self.stylesheet['Fineprint'] - )) - - return story - - -class Modern1Renderer(ClassicInvoiceRenderer): - identifier = 'modern1' - verbose_name = gettext_lazy('Default invoice renderer (European-style letter)') - bottom_margin = 16.9 * mm - top_margin = 16.9 * mm - right_margin = 20 * mm - invoice_to_height = 27.3 * mm - invoice_to_width = 80 * mm - invoice_to_left = 25 * mm - invoice_to_top = (40 + 17.7) * mm - invoice_from_left = 125 * mm - invoice_from_top = 50 * mm - invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin - invoice_from_height = 50 * mm - - logo_width = 75 * mm - logo_height = 25 * mm - logo_left = pagesizes.A4[0] - logo_width - right_margin - logo_top = top_margin - logo_anchor = 'e' - - event_left = 25 * mm - event_top = top_margin - event_width = 80 * mm - event_height = 25 * mm - - def _get_stylesheet(self): - stylesheet = super()._get_stylesheet() - stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10)) - stylesheet['InvoiceFrom'].alignment = TA_RIGHT - return stylesheet - - def _draw_invoice_from(self, canvas): - if not self.invoice.invoice_from: - return - c = [ - self._clean_text(l) - for l in self.invoice.address_invoice_from.strip().split('\n') - ] - p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender']) - p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) - p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm) - super()._draw_invoice_from(canvas) - - def _draw_invoice_to_label(self, canvas): - pass - - def _draw_invoice_from_label(self, canvas): - pass - - def _draw_event_label(self, canvas): - pass - - def _get_first_page_frames(self, doc): - footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm - if self.event.settings.invoice_renderer_highlight_order_code: - margin_top = 100 * mm - else: - margin_top = 95 * mm - return [ - Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - margin_top, - leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, - id='normal') - ] - - def _draw_metadata(self, canvas): - # Draws the "invoice number -- date" line. This has gotten a little more complicated since we - # encountered some events with very long invoice numbers. In this case, we automatically reduce - # the font size until it fits. - begin_top = 100 * mm - - def _draw(label, value, value_size, x, width, bold=False, sublabel=None): - if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6: - return False - textobject = canvas.beginText(x, self.pagesize[1] - begin_top) - textobject.setFont(self.font_regular, 8) - textobject.textLine(self._normalize(label)) - textobject.moveCursor(0, 5) - textobject.setFont(self.font_bold if bold else self.font_regular, value_size) - textobject.textLine(self._normalize(value)) - - if sublabel: - textobject.moveCursor(0, 1) - textobject.setFont(self.font_regular, 8) - textobject.textLine(self._normalize(sublabel)) - - return textobject - - value_size = 10 - while value_size >= 5: - if self.event.settings.invoice_renderer_highlight_order_code: - kwargs = dict(bold=True, sublabel=pgettext('invoice', '(Please quote at all times.)')) - else: - kwargs = {} - objects = [ - _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) - ] - - p = FontFallbackParagraph( - self._normalize(date_format(self.invoice.date, "DATE_FORMAT")), - style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2) - ) - w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) - p.wrapOn(canvas, w, 15 * mm) - date_x = self.pagesize[0] - w - self.right_margin - - if self.invoice.is_cancellation: - objects += [ - _draw(pgettext('invoice', 'Cancellation number'), self.invoice.number, - value_size, self.left_margin + 50 * mm, 45 * mm), - _draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number, - value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm), - ] - else: - objects += [ - _draw(pgettext('invoice', 'Invoice number'), self.invoice.number, - value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm), - ] - - if all(objects): - for o in objects: - canvas.drawText(o) - break - value_size -= 1 - - p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6) - - textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top) - textobject.setFont(self.font_regular, 8) - if self.invoice.is_cancellation: - textobject.textLine(self._normalize(pgettext('invoice', 'Cancellation date'))) - else: - textobject.textLine(self._normalize(pgettext('invoice', 'Invoice date'))) - canvas.drawText(textobject) - - -class Modern1SimplifiedRenderer(Modern1Renderer): - identifier = 'modern1simplified' - verbose_name = gettext_lazy('Simplified invoice renderer') - - logo_left = Modern1Renderer.left_margin - logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left - logo_height = 25 * mm - logo_top = 13 * mm - logo_anchor = 'nw' - - def _draw_invoice_from(self, canvas): - super(Modern1Renderer, self)._draw_invoice_from(canvas) - - def _draw_event(self, canvas): - pass - - def _get_intro(self): - i = [] - - if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: - i.append(FontFallbackParagraph( - pgettext('invoice', 'Event date: {date_range}').format( - date_range=self.invoice.event.get_date_range_display(), - ), - self.stylesheet['Normal'], - )) - i.append(Spacer(2 * mm, 2 * mm)) - - return i + super()._get_intro() - - -@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") -def recv_classic(sender, **kwargs): - return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer] +# This module consists for backwards compatibility of imports from plugins. +__all__ = [ + "addon_aware_groupby", + "NumberedCanvas", + "BaseInvoiceRenderer", + "BaseReportlabInvoiceRenderer", + "ClassicInvoiceRenderer", + "Modern1Renderer", + "Modern1SimplifiedRenderer", + "ThumbnailingImageReader", +] diff --git a/src/pretix/base/invoicing/__init__.py b/src/pretix/base/invoicing/__init__.py new file mode 100644 index 0000000000..9fd5bdc500 --- /dev/null +++ b/src/pretix/base/invoicing/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/pretix/base/invoicing/email.py b/src/pretix/base/invoicing/email.py new file mode 100644 index 0000000000..97d47d96f7 --- /dev/null +++ b/src/pretix/base/invoicing/email.py @@ -0,0 +1,173 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from django import forms +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django_countries.fields import Country +from i18nfield.strings import LazyI18nString + +from pretix.base.email import get_email_context +from pretix.base.i18n import language +from pretix.base.invoicing.transmission import ( + TransmissionProvider, TransmissionType, transmission_providers, + transmission_types, +) +from pretix.base.models import Invoice, InvoiceAddress +from pretix.base.services.mail import SendMailException, mail, render_mail +from pretix.helpers.format import format_map + + +@transmission_types.new() +class EmailTransmissionType(TransmissionType): + identifier = "email" + verbose_name = _("Email") + priority = 1000 + + @property + def invoice_address_form_fields(self) -> dict: + return { + "transmission_email_other": forms.BooleanField( + label=_("Email invoice directly to accounting department"), + help_text=_("If not selected, the invoice will be sent to you using the email address listed above."), + required=False, + ), + "transmission_email_address": forms.EmailField( + label=_("Email address for invoice"), + widget=forms.EmailInput( + attrs={"data-display-dependency": "#id_transmission_email_other"} + ) + ) + } + + def invoice_address_form_fields_visible(self, country: Country, is_business: bool): + if is_business: + # We don't want ask non-business users if they have an accounting department ;) + return {"transmission_email_other", "transmission_email_address"} + return set() + + def is_available(self, event, country: Country, is_business: bool): + # Skip availability check since provider is always available and we do not want to end up without invoice + # transmission type + return True + + def transmission_info_to_form_data(self, transmission_info: dict) -> dict: + return { + "transmission_email_other": bool(transmission_info.get("transmission_email_address")), + "transmission_email_address": transmission_info.get("transmission_email_address"), + } + + def form_data_to_transmission_info(self, form_data: dict) -> dict: + if form_data.get("transmission_email_other") and form_data.get("transmission_email_address"): + return { + "transmission_email_address": form_data["transmission_email_address"], + } + return {} + + +@transmission_providers.new() +class EmailTransmissionProvider(TransmissionProvider): + identifier = "email_pdf" + type = "email" + verbose_name = _("PDF via email") + priority = 1000 + testmode_supported = True + + def is_ready(self, event) -> bool: + return True + + def is_available(self, event, country: Country, is_business: bool) -> bool: + return True + + def transmit(self, invoice: Invoice): + info = (invoice.invoice_to_transmission_info or {}) + if info.get("transmission_email_address"): + recipient = info["transmission_email_address"] + else: + recipient = invoice.order.email + + if not recipient: + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + invoice.order.log_action( + "pretix.event.order.invoice.sending_failed", + data={ + "full_invoice_no": invoice.full_invoice_no, + "transmission_provider": "email_pdf", + "transmission_type": "email", + "data": { + "reason": "no_recipient", + }, + } + ) + return + + with language(invoice.order.locale, invoice.order.event.settings.region): + context = get_email_context( + event=invoice.order.event, + order=invoice.order, + invoice=invoice, + event_or_subevent=invoice.order.event, + invoice_address=getattr(invoice.order, 'invoice_address', None) or InvoiceAddress() + ) + template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString) + subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString) + + try: + # Do not set to completed because that is done by the email sending task + subject = format_map(subject, context) + email_content = render_mail(template, context) + mail( + [recipient], + subject, + template, + context=context, + event=invoice.order.event, + locale=invoice.order.locale, + order=invoice.order, + invoices=[invoice], + attach_tickets=False, + auto_email=True, + attach_ical=False, + plain_text_only=True, + no_order_links=True, + ) + except SendMailException: + raise + else: + invoice.order.log_action( + 'pretix.event.order.email.invoice', + user=None, + auth=None, + data={ + 'subject': subject, + 'message': email_content, + 'position': None, + 'recipient': recipient, + 'invoices': [invoice.pk], + 'attach_tickets': False, + 'attach_ical': False, + 'attach_other_files': [], + 'attach_cached_files': [], + } + ) diff --git a/src/pretix/base/invoicing/national.py b/src/pretix/base/invoicing/national.py new file mode 100644 index 0000000000..4d37c87039 --- /dev/null +++ b/src/pretix/base/invoicing/national.py @@ -0,0 +1,75 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from django import forms +from django.core.validators import RegexValidator +from django.utils.translation import pgettext_lazy +from django_countries.fields import Country +from localflavor.it.forms import ITSocialSecurityNumberField + +from pretix.base.invoicing.transmission import ( + TransmissionType, transmission_types, +) + + +@transmission_types.new() +class ItalianSdITransmissionType(TransmissionType): + identifier = "it_sdi" + verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)") + public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)") + exclusive = True + enforce_transmission = True + + def is_available(self, event, country: Country, is_business: bool): + return str(country) == "IT" and super().is_available(event, country, is_business) + + @property + def invoice_address_form_fields(self) -> dict: + return { + "transmission_it_sdi_codice_fiscale": ITSocialSecurityNumberField( + label=pgettext_lazy("italian_invoice", "Fiscal code"), + required=False, + ), + "transmission_it_sdi_pec": forms.EmailField( + label=pgettext_lazy("italian_invoice", "Address for certified electronical mail"), + widget=forms.EmailInput() + ), + "transmission_it_sdi_recipient_code": forms.CharField( + label=pgettext_lazy("italian_invoice", "Recipient code"), + validators=[ + RegexValidator("^[A-Z0-9]{6,7}$") + ] + ), + } + + def invoice_address_form_fields_visible(self, country: Country, is_business: bool): + if is_business: + return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"} + return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec"} + + def invoice_address_form_fields_required(self, country: Country, is_business: bool): + base = { + "street", "zipcode", "city", "state", "country", + } + if is_business: + return base | {"company", "vat_id", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"} + return base | {"transmission_it_sdi_codice_fiscale"} diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py new file mode 100644 index 0000000000..843e21bcd7 --- /dev/null +++ b/src/pretix/base/invoicing/pdf.py @@ -0,0 +1,1107 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import logging +import re +import unicodedata +from collections import defaultdict +from decimal import Decimal +from io import BytesIO +from itertools import groupby +from typing import Tuple + +import bleach +import vat_moss.exchange_rates +from bidi import get_display +from django.contrib.staticfiles import finders +from django.db.models import Sum +from django.dispatch import receiver +from django.utils.formats import date_format, localize +from django.utils.translation import ( + get_language, gettext, gettext_lazy, pgettext, +) +from reportlab.lib import colors, pagesizes +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from reportlab.lib.styles import ParagraphStyle, StyleSheet1 +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import ( + BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate, + PageTemplate, Spacer, Table, TableStyle, +) + +from pretix.base.decimal import round_decimal +from pretix.base.models import Event, Invoice, Order, OrderPayment +from pretix.base.services.currencies import SOURCE_NAMES +from pretix.base.signals import register_invoice_renderers +from pretix.base.templatetags.money import money_filter +from pretix.helpers.reportlab import ( + FontFallbackParagraph, ThumbnailingImageReader, reshaper, +) +from pretix.presale.style import get_fonts + +logger = logging.getLogger(__name__) + + +def addon_aware_groupby(iterable, key, is_addon): + """ + We use groupby() to visually group identical lines on an invoice. For example, instead of + + Product 1 5.00 EUR + Product 1 5.00 EUR + Product 1 5.00 EUR + Product 2 7.00 EUR + + We want to print + + 3x Product 1 5.00 EUR = 15.00 EUR + Product 2 7.00 EUR + + However, this fails for setups with addon-products since groupby() only groups consecutive + lines with the same identity. So in + + Product 1 5.00 EUR + + Addon 1 2.00 EUR + Product 1 5.00 EUR + + Addon 1 2.00 EUR + Product 1 5.00 EUR + + Addon 2 3.00 EUR + + There is no consecutive repetition of the same entity. This function provides a specialised groupby which + understands the product/addon relationship and packs groups of these addons together if they are, in fact, + identical groups: + + 2x Product 1 5.00 EUR = 10.00 EUR + + 2x Addon 1 2.00 EUR = 4.00 EUR + Product 1 5.00 EUR + + Addon 2 3.00 EUR + """ + packed_groups = [] + + for i in iterable: + if is_addon(i): + packed_groups[-1].append(i) + else: + packed_groups.append([i]) + # Each packed_groups element contains a list with the parent product as first element, and any addon products following + + def _reorder(packed_groups): + # Emit the products as individual products again, reordered by "all parent products, then all addon products" + # within each group. + for _, repeated_groups in groupby(packed_groups, key=lambda g: tuple(key(a) for a in g)): + for repeated_items in zip(*repeated_groups): + yield from repeated_items + + return groupby(_reorder(packed_groups), key) + + +class NumberedCanvas(Canvas): + def __init__(self, *args, **kwargs): + self.font_regular = kwargs.pop('font_regular') + super().__init__(*args, **kwargs) + self._saved_page_states = [] + + def showPage(self): + self._saved_page_states.append(dict(self.__dict__)) + self._startPage() + + def save(self): + num_pages = len(self._saved_page_states) + for state in self._saved_page_states: + self.__dict__.update(state) + self.draw_page_number(num_pages) + Canvas.showPage(self) + Canvas.save(self) + + def draw_page_number(self, page_count): + self.saveState() + self.setFont(self.font_regular, 8) + text = pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,) + try: + text = get_display(reshaper.reshape(text)) + except: + logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) + self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, text) + self.restoreState() + + +class BaseInvoiceRenderer: + """ + This is the base class for all invoice renderers. + """ + + def __init__(self, event: Event): + self.event = event + + def __str__(self): + return self.identifier + + def generate(self, invoice: Invoice) -> Tuple[str, str, str]: + """ + This method should generate the invoice file and return a tuple consisting of a + filename, a file type and file content. The extension will be taken from the filename + which is otherwise ignored. + """ + raise NotImplementedError() + + @property + def verbose_name(self) -> str: + """ + A human-readable name for this renderer. This should be short but + self-explanatory. Good examples include 'German DIN 5008' or 'Italian invoice'. + """ + raise NotImplementedError() # NOQA + + @property + def identifier(self) -> str: + """ + A short and unique identifier for this renderer. + This should only contain lowercase letters and in most + cases will be the same as your package name. + """ + raise NotImplementedError() # NOQA + + +class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): + """ + This is a convenience class to avoid duplicate code when implementing invoice renderers + that are based on reportlab. + """ + pagesize = pagesizes.A4 + left_margin = 25 * mm + right_margin = 20 * mm + top_margin = 20 * mm + bottom_margin = 15 * mm + doc_template_class = BaseDocTemplate + canvas_class = Canvas + font_regular = 'OpenSans' + font_bold = 'OpenSansBd' + + def _init(self): + """ + Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``. + """ + self._register_fonts() + self.stylesheet = self._get_stylesheet() + + def _get_stylesheet(self): + """ + Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles. + """ + stylesheet = StyleSheet1() + stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12)) + stylesheet.add(ParagraphStyle(name='Bold', fontName=self.font_bold, fontSize=10, leading=12)) + stylesheet.add(ParagraphStyle(name='BoldRight', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT)) + stylesheet.add(ParagraphStyle(name='BoldRightNoSplit', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT, + splitLongWords=False)) + stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT)) + stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12, + textColor=colors.white, alignment=TA_CENTER)) + stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal'])) + stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2)) + stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12)) + stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10)) + stylesheet.add(ParagraphStyle(name='FineprintRight', fontName=self.font_regular, fontSize=8, leading=10, alignment=TA_RIGHT)) + return stylesheet + + def _register_fonts(self): + """ + Register fonts with reportlab. By default, this registers the OpenSans font family + """ + pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))) + pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', + italic='OpenSansIt', boldItalic='OpenSansBI') + + for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): + pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) + if family == self.event.settings.invoice_renderer_font: + self.font_regular = family + if 'bold' in styles: + self.font_bold = family + ' B' + if 'italic' in styles: + pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) + if 'bold' in styles: + pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype']))) + if 'bolditalic' in styles: + pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype']))) + + def _normalize(self, text): + # reportlab does not support unicode combination characters + # It's important we do this before we use ArabicReshaper + text = unicodedata.normalize("NFKC", text) + + # reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper + # to resolve all ligatures and python-bidi to switch RTL texts. + try: + text = "
".join(get_display(reshaper.reshape(l)) for l in re.split("
", text)) + except: + logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) + + return text + + def _upper(self, val): + # We uppercase labels, but not in every language + if get_language().startswith('el'): + return val + return val.upper() + + def _on_other_page(self, canvas: Canvas, doc): + """ + Called when a new page is rendered that is *not* the first page. + """ + pass + + def _on_first_page(self, canvas: Canvas, doc): + """ + Called when a new page is rendered that is the first page. + """ + pass + + def _get_story(self, doc): + """ + Called to create the story to be inserted into the main frames. + """ + raise NotImplementedError() + + def _get_first_page_frames(self, doc): + """ + Called to create a list of frames for the first page. + """ + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='normal') + ] + + def _get_other_page_frames(self, doc): + """ + Called to create a list of frames for the other pages. + """ + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, + id='normal') + ] + + def _build_doc(self, fhandle): + """ + Build a PDF document in a given file handle + """ + self._init() + doc = self.doc_template_class(fhandle, pagesize=self.pagesize, + leftMargin=self.left_margin, rightMargin=self.right_margin, + topMargin=self.top_margin, bottomMargin=self.bottom_margin) + + doc.addPageTemplates([ + PageTemplate( + id='FirstPage', + frames=self._get_first_page_frames(doc), + onPage=self._on_first_page, + pagesize=self.pagesize + ), + PageTemplate( + id='OtherPages', + frames=self._get_other_page_frames(doc), + onPage=self._on_other_page, + pagesize=self.pagesize + ) + ]) + story = self._get_story(doc) + doc.build(story, canvasmaker=self.canvas_class) + return doc + + def generate(self, invoice: Invoice): + self.invoice = invoice + buffer = BytesIO() + self._build_doc(buffer) + buffer.seek(0) + return 'invoice.pdf', 'application/pdf', buffer.read() + + def _clean_text(self, text, tags=None): + return self._normalize(bleach.clean( + text, + tags=set(tags) if tags else set() + ).strip().replace('
', '
').replace('\n', '
\n')) + + +class PaidMarker(Flowable): + def __init__(self, text='paid', color=None, font='OpenSansBd', size=20): + super().__init__() + self.text = text + self.color = color + self.font = font + self.size = size + self._showBoundary = True + + def wrap(self, availwidth, availheight): + # Fake a size, we don't care if we exceed the table + return 10, self.size / 2 + + def draw(self): + self.canv.translate(0, - self.size / 2) + self.canv.rotate(2) + self.canv.setFont(self.font, self.size) + self.canv.setFillColor(self.color) + width = self.canv.stringWidth(self.text, self.font, self.size) + self.canv.drawRightString(0, 0, self.text) + + self.canv.setStrokeColor(self.color) + self.canv.roundRect(-width - self.size / 2, -self.size / 4, width + self.size, self.size + self.size / 4, 3) + + +class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): + identifier = 'classic' + verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') + + def canvas_class(self, *args, **kwargs): + kwargs['font_regular'] = self.font_regular + return NumberedCanvas(*args, **kwargs) + + def _on_other_page(self, canvas: Canvas, doc): + canvas.saveState() + canvas.setFont(self.font_regular, 8) + + for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): + canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) + + canvas.restoreState() + + invoice_to_width = 85 * mm + invoice_to_height = 50 * mm + invoice_to_left = 25 * mm + invoice_to_top = 52 * mm + + def _draw_invoice_to(self, canvas): + p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to), + style=self.stylesheet['Normal']) + p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) + p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) + p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) + + invoice_from_width = 70 * mm + invoice_from_height = 50 * mm + invoice_from_left = 25 * mm + invoice_from_top = 17 * mm + + def _draw_invoice_from(self, canvas): + p = FontFallbackParagraph( + self._clean_text(self.invoice.full_invoice_from), + style=self.stylesheet['InvoiceFrom'] + ) + p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) + p_size = p.wrap(self.invoice_from_width, self.invoice_from_height) + p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top) + + def _draw_invoice_from_label(self, canvas): + textobject = canvas.beginText(25 * mm, (297 - 15) * mm) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice from')))) + canvas.drawText(textobject) + + def _draw_invoice_to_label(self, canvas): + textobject = canvas.beginText(25 * mm, (297 - 50) * mm) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice to')))) + canvas.drawText(textobject) + + logo_width = 25 * mm + logo_height = 25 * mm + logo_left = 95 * mm + logo_top = 13 * mm + logo_anchor = 'n' + + def _draw_logo(self, canvas): + if self.invoice.event.settings.invoice_logo_image: + logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True) + ir = ThumbnailingImageReader(logo_file) + try: + ir.resize(self.logo_width, self.logo_height, 300) + except: + logger.exception("Can not resize image") + pass + try: + # Valid ZUGFeRD invoices must be compliant with PDF/A-3. pretix-zugferd ensures this by passing them + # through ghost script. Unfortunately, if the logo contains transparency, this will still fail. + # I was unable to figure out a way to fix this in GhostScript, so the easy fix is to remove the + # transparency, as our invoices always have a white background anyways. + ir.remove_transparency() + except: + logger.exception("Can not remove transparency from logo") + pass + canvas.drawImage(ir, + self.logo_left, + self.pagesize[1] - self.logo_height - self.logo_top, + width=self.logo_width, height=self.logo_height, + preserveAspectRatio=True, anchor=self.logo_anchor, + mask='auto') + + def _draw_metadata(self, canvas): + textobject = canvas.beginText(125 * mm, (297 - 38) * mm) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Order code')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(self.invoice.order.full_code)) + canvas.drawText(textobject) + + textobject = canvas.beginText(125 * mm, (297 - 50) * mm) + textobject.setFont(self.font_bold, 8) + if self.invoice.is_cancellation: + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation number')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(self.invoice.number)) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(self.invoice.refers.number)) + else: + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice number')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(self.invoice.number)) + textobject.moveCursor(0, 5) + + if self.invoice.is_cancellation: + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation date')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice date')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(date_format(self.invoice.refers.date, "DATE_FORMAT"))) + textobject.moveCursor(0, 5) + else: + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice date')))) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_regular, 10) + textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) + textobject.moveCursor(0, 5) + + canvas.drawText(textobject) + + event_left = 125 * mm + event_top = 17 * mm + event_width = 65 * mm + event_height = 50 * mm + + def _draw_event_label(self, canvas): + textobject = canvas.beginText(125 * mm, (297 - 15) * mm) + textobject.setFont(self.font_bold, 8) + textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event')))) + canvas.drawText(textobject) + + def _draw_event(self, canvas): + def shorten(txt): + txt = str(txt) + txt = bleach.clean(txt, tags=set()).strip() + p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) + p_size = p.wrap(self.event_width, self.event_height) + + while p_size[1] > 2 * self.stylesheet['Normal'].leading: + txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…' + p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) + p_size = p.wrap(self.event_width, self.event_height) + return txt + + if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: + tz = self.invoice.event.timezone + show_end_date = ( + self.invoice.event.settings.show_date_to and + self.invoice.event.date_to and + self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date() + ) + if show_end_date: + p_str = ( + shorten(self.invoice.event.name) + '\n' + + pgettext('invoice', '{from_date}\nuntil {to_date}').format( + from_date=self.invoice.event.get_date_from_display(show_times=False), + to_date=self.invoice.event.get_date_to_display(show_times=False) + ) + ) + else: + p_str = ( + shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False) + ) + else: + p_str = shorten(self.invoice.event.name) + + p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) + p.wrapOn(canvas, self.event_width, self.event_height) + p_size = p.wrap(self.event_width, self.event_height) + p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) + self._draw_event_label(canvas) + + def _draw_footer(self, canvas): + canvas.setFont(self.font_regular, 8) + for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): + canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) + + def _draw_testmode(self, canvas): + if self.invoice.order.testmode: + canvas.saveState() + canvas.setFont(self.font_bold, 30) + canvas.setFillColorRGB(32, 0, 0) + canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, self._normalize(gettext('TEST MODE'))) + canvas.restoreState() + + def _on_first_page(self, canvas: Canvas, doc): + canvas.setCreator('pretix.eu') + canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number)) + + canvas.saveState() + self._draw_footer(canvas) + self._draw_testmode(canvas) + self._draw_invoice_from_label(canvas) + self._draw_invoice_from(canvas) + self._draw_invoice_to_label(canvas) + self._draw_invoice_to(canvas) + self._draw_metadata(canvas) + self._draw_logo(canvas) + self._draw_event(canvas) + canvas.restoreState() + + def _get_first_page_frames(self, doc): + footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, + id='normal') + ] + + def _get_other_page_frames(self, doc): + footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, + id='normal') + ] + + def _get_intro(self): + story = [] + if self.invoice.custom_field: + story.append(FontFallbackParagraph( + '{}: {}'.format( + self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)), + self._clean_text(self.invoice.custom_field), + ), + self.stylesheet['Normal'] + )) + + if self.invoice.internal_reference: + story.append(FontFallbackParagraph( + self._normalize(pgettext('invoice', 'Customer reference: {reference}').format( + reference=self._clean_text(self.invoice.internal_reference), + )), + self.stylesheet['Normal'] + )) + + if self.invoice.invoice_to_vat_id: + story.append(FontFallbackParagraph( + self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' + + self._clean_text(self.invoice.invoice_to_vat_id), + self.stylesheet['Normal'] + )) + + if self.invoice.invoice_to_beneficiary: + story.append(FontFallbackParagraph( + self._normalize(pgettext('invoice', 'Beneficiary')) + ':
' + + self._clean_text(self.invoice.invoice_to_beneficiary), + self.stylesheet['Normal'] + )) + + if self.invoice.introductory_text: + # While all intro fields are appended without any blank lines; we do want one before the optional intro + # text. However, if there are no prior intro fields, adding an additional spacer will waste space. + if story: + story.append(Spacer(1, 5 * mm)) + + story.append(FontFallbackParagraph( + self._clean_text(self.invoice.introductory_text, tags=['br']), + self.stylesheet['Normal'] + )) + story.append(Spacer(1, 5 * mm)) + + return story + + def _get_story(self, doc): + has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge + + story = [ + NextPageTemplate('FirstPage'), + FontFallbackParagraph( + self._normalize( + pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' + else pgettext('invoice', 'Invoice') + ) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')), + self.stylesheet['Heading1'] + ), + Spacer(1, 5 * mm), + NextPageTemplate('OtherPages'), + ] + story += self._get_intro() + + taxvalue_map = defaultdict(Decimal) + grossvalue_map = defaultdict(Decimal) + + tstyledata = [ + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('FONTNAME', (0, 0), (-1, -1), self.font_regular), + ('FONTNAME', (0, 0), (-1, 0), self.font_bold), + ('FONTNAME', (0, -1), (-1, -1), self.font_bold), + ('LEFTPADDING', (0, 0), (0, -1), 0), + ('RIGHTPADDING', (-1, 0), (-1, -1), 0), + ] + if has_taxes: + tdata = [( + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']), + )] + else: + tdata = [( + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']), + )] + + def _group_key(line): + return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id, + line.event_date_from, line.event_date_to) + + total = Decimal('0.00') + for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby( + self.invoice.lines.all(), + key=_group_key, + is_addon=lambda l: l.description.startswith(" +"), + ): + lines = list(lines) + if has_taxes: + if len(lines) > 1: + single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format( + net_price=money_filter(net_value, self.invoice.event.currency), + gross_price=money_filter(gross_value, self.invoice.event.currency), + ) + description = description + "\n" + single_price_line + tdata.append(( + FontFallbackParagraph( + self._clean_text(description, tags=['br']), + self.stylesheet['Normal'] + ), + str(len(lines)), + localize(tax_rate) + " %", + FontFallbackParagraph( + money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), + self.stylesheet['NormalRight'] + ), + FontFallbackParagraph( + money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), + self.stylesheet['NormalRight'] + ), + )) + else: + if len(lines) > 1: + single_price_line = pgettext('invoice', 'Single price: {price}').format( + price=money_filter(gross_value, self.invoice.event.currency), + ) + description = description + "\n" + single_price_line + tdata.append(( + FontFallbackParagraph( + self._clean_text(description, tags=['br']), + self.stylesheet['Normal'] + ), + str(len(lines)), + FontFallbackParagraph( + money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), + self.stylesheet['NormalRight'] + ), + )) + taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines) + grossvalue_map[tax_rate, tax_name] += gross_value * len(lines) + total += gross_value * len(lines) + + if has_taxes: + tdata.append([ + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', + money_filter(total, self.invoice.event.currency) + ]) + colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)] + else: + tdata.append([ + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', + money_filter(total, self.invoice.event.currency) + ]) + colwidths = [a * doc.width for a in (.65, .20, .15)] + + if not self.invoice.is_cancellation: + if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING: + pending_sum = self.invoice.order.pending_sum + if pending_sum != total: + tdata.append( + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] + + (['', '', ''] if has_taxes else ['']) + + [money_filter(pending_sum - total, self.invoice.event.currency)] + ) + tdata.append( + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] + + (['', '', ''] if has_taxes else ['']) + + [money_filter(pending_sum, self.invoice.event.currency)] + ) + tstyledata += [ + ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), + ] + elif self.invoice.event.settings.invoice_show_payments and self.invoice.order.payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard' + ).exists(): + giftcard_sum = self.invoice.order.payments.filter( + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + provider='giftcard' + ).aggregate( + s=Sum('amount') + )['s'] or Decimal('0.00') + tdata.append( + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] + + (['', '', ''] if has_taxes else ['']) + + [money_filter(giftcard_sum, self.invoice.event.currency)] + ) + tdata.append( + [FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] + + (['', '', ''] if has_taxes else ['']) + + [money_filter(total - giftcard_sum, self.invoice.event.currency)] + ) + tstyledata += [ + ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), + ] + elif self.invoice.payment_provider_stamp: + pm = PaidMarker( + text=self._normalize(self.invoice.payment_provider_stamp), + color=colors.HexColor(self.event.settings.theme_color_success), + font=self.font_bold, + size=16 + ) + tdata[-1][-2] = pm + + table = Table(tdata, colWidths=colwidths, repeatRows=1) + table.setStyle(TableStyle(tstyledata)) + story.append(table) + + story.append(Spacer(1, 10 * mm)) + + if self.invoice.payment_provider_text: + story.append(FontFallbackParagraph( + self._normalize(self.invoice.payment_provider_text), + self.stylesheet['Normal'] + )) + + if self.invoice.payment_provider_text and self.invoice.additional_text: + story.append(Spacer(1, 3 * mm)) + + if self.invoice.additional_text: + story.append(FontFallbackParagraph( + self._clean_text(self.invoice.additional_text, tags=['br']), + self.stylesheet['Normal'] + )) + story.append(Spacer(1, 5 * mm)) + + tstyledata = [ + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ('LEFTPADDING', (0, 0), (0, -1), 0), + ('RIGHTPADDING', (-1, 0), (-1, -1), 0), + ('TOPPADDING', (0, 0), (-1, -1), 1), + ('BOTTOMPADDING', (0, 0), (-1, -1), 1), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('FONTNAME', (0, 0), (-1, -1), self.font_regular), + ] + thead = [ + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']), + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']), + '' + ] + tdata = [thead] + + for idx, gross in grossvalue_map.items(): + rate, name = idx + if rate == 0 and gross == 0: + continue + tax = taxvalue_map[idx] + tdata.append([ + FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), + money_filter(gross - tax, self.invoice.event.currency), + money_filter(gross, self.invoice.event.currency), + money_filter(tax, self.invoice.event.currency), + '' + ]) + + def fmt(val): + try: + return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) + except ValueError: + return localize(val) + ' ' + self.invoice.foreign_currency_display + + if any(rate != 0 and gross != 0 for (rate, name), gross in grossvalue_map.items()) and has_taxes: + colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)] + table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) + table.setStyle(TableStyle(tstyledata)) + story.append(Spacer(5 * mm, 5 * mm)) + story.append(KeepTogether([ + FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']), + table + ])) + + if self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: + tdata = [thead] + + for idx, gross in grossvalue_map.items(): + rate, name = idx + if rate == 0: + continue + tax = taxvalue_map[idx] + gross = round_decimal(gross * self.invoice.foreign_currency_rate) + tax = round_decimal(tax * self.invoice.foreign_currency_rate) + net = gross - tax + + tdata.append([ + FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), + fmt(net), fmt(gross), fmt(tax), '' + ]) + + table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) + table.setStyle(TableStyle(tstyledata)) + + story.append(KeepTogether([ + Spacer(1, height=2 * mm), + FontFallbackParagraph( + self._normalize(pgettext( + 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' + '{date}, this corresponds to:' + ).format(rate=localize(self.invoice.foreign_currency_rate), + authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), + date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))), + self.stylesheet['Fineprint'] + ), + Spacer(1, height=3 * mm), + table + ])) + elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: + foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) + story.append(Spacer(1, 5 * mm)) + story.append(FontFallbackParagraph(self._normalize( + pgettext( + 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' + '{date}, the invoice total corresponds to {total}.' + ).format(rate=localize(self.invoice.foreign_currency_rate), + date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), + authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), + total=fmt(foreign_total))), + self.stylesheet['Fineprint'] + )) + + return story + + +class Modern1Renderer(ClassicInvoiceRenderer): + identifier = 'modern1' + verbose_name = gettext_lazy('Default invoice renderer (European-style letter)') + bottom_margin = 16.9 * mm + top_margin = 16.9 * mm + right_margin = 20 * mm + invoice_to_height = 27.3 * mm + invoice_to_width = 80 * mm + invoice_to_left = 25 * mm + invoice_to_top = (40 + 17.7) * mm + invoice_from_left = 125 * mm + invoice_from_top = 50 * mm + invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin + invoice_from_height = 50 * mm + + logo_width = 75 * mm + logo_height = 25 * mm + logo_left = pagesizes.A4[0] - logo_width - right_margin + logo_top = top_margin + logo_anchor = 'e' + + event_left = 25 * mm + event_top = top_margin + event_width = 80 * mm + event_height = 25 * mm + + def _get_stylesheet(self): + stylesheet = super()._get_stylesheet() + stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10)) + stylesheet['InvoiceFrom'].alignment = TA_RIGHT + return stylesheet + + def _draw_invoice_from(self, canvas): + if not self.invoice.invoice_from: + return + c = [ + self._clean_text(l) + for l in self.invoice.address_invoice_from.strip().split('\n') + ] + p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender']) + p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) + p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm) + super()._draw_invoice_from(canvas) + + def _draw_invoice_to_label(self, canvas): + pass + + def _draw_invoice_from_label(self, canvas): + pass + + def _draw_event_label(self, canvas): + pass + + def _get_first_page_frames(self, doc): + footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm + if self.event.settings.invoice_renderer_highlight_order_code: + margin_top = 100 * mm + else: + margin_top = 95 * mm + return [ + Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - margin_top, + leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, + id='normal') + ] + + def _draw_metadata(self, canvas): + # Draws the "invoice number -- date" line. This has gotten a little more complicated since we + # encountered some events with very long invoice numbers. In this case, we automatically reduce + # the font size until it fits. + begin_top = 100 * mm + + def _draw(label, value, value_size, x, width, bold=False, sublabel=None): + if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6: + return False + textobject = canvas.beginText(x, self.pagesize[1] - begin_top) + textobject.setFont(self.font_regular, 8) + textobject.textLine(self._normalize(label)) + textobject.moveCursor(0, 5) + textobject.setFont(self.font_bold if bold else self.font_regular, value_size) + textobject.textLine(self._normalize(value)) + + if sublabel: + textobject.moveCursor(0, 1) + textobject.setFont(self.font_regular, 8) + textobject.textLine(self._normalize(sublabel)) + + return textobject + + value_size = 10 + while value_size >= 5: + if self.event.settings.invoice_renderer_highlight_order_code: + kwargs = dict(bold=True, sublabel=pgettext('invoice', '(Please quote at all times.)')) + else: + kwargs = {} + objects = [ + _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) + ] + + p = FontFallbackParagraph( + self._normalize(date_format(self.invoice.date, "DATE_FORMAT")), + style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2) + ) + w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) + p.wrapOn(canvas, w, 15 * mm) + date_x = self.pagesize[0] - w - self.right_margin + + if self.invoice.is_cancellation: + objects += [ + _draw(pgettext('invoice', 'Cancellation number'), self.invoice.number, + value_size, self.left_margin + 50 * mm, 45 * mm), + _draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number, + value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm), + ] + else: + objects += [ + _draw(pgettext('invoice', 'Invoice number'), self.invoice.number, + value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm), + ] + + if all(objects): + for o in objects: + canvas.drawText(o) + break + value_size -= 1 + + p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6) + + textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top) + textobject.setFont(self.font_regular, 8) + if self.invoice.is_cancellation: + textobject.textLine(self._normalize(pgettext('invoice', 'Cancellation date'))) + else: + textobject.textLine(self._normalize(pgettext('invoice', 'Invoice date'))) + canvas.drawText(textobject) + + +class Modern1SimplifiedRenderer(Modern1Renderer): + identifier = 'modern1simplified' + verbose_name = gettext_lazy('Simplified invoice renderer') + + logo_left = Modern1Renderer.left_margin + logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left + logo_height = 25 * mm + logo_top = 13 * mm + logo_anchor = 'nw' + + def _draw_invoice_from(self, canvas): + super(Modern1Renderer, self)._draw_invoice_from(canvas) + + def _draw_event(self, canvas): + pass + + def _get_intro(self): + i = [] + + if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: + i.append(FontFallbackParagraph( + pgettext('invoice', 'Event date: {date_range}').format( + date_range=self.invoice.event.get_date_range_display(), + ), + self.stylesheet['Normal'], + )) + i.append(Spacer(2 * mm, 2 * mm)) + + return i + super()._get_intro() + + +@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") +def recv_classic(sender, **kwargs): + return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer] diff --git a/src/pretix/base/invoicing/peppol.py b/src/pretix/base/invoicing/peppol.py new file mode 100644 index 0000000000..8050805901 --- /dev/null +++ b/src/pretix/base/invoicing/peppol.py @@ -0,0 +1,167 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import re + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django_countries.fields import Country + +from pretix.base.invoicing.transmission import ( + TransmissionType, transmission_types, +) + + +class PeppolIdValidator: + regex_rules = { + # Source: https://docs.peppol.eu/edelivery/codelists/old/v8.5/Peppol%20Code%20Lists%20-%20Participant%20identifier%20schemes%20v8.5.html + "0002": "[0-9]{9}([0-9]{5})?", + "0007": "[0-9]{10}", + "0009": "[0-9]{14}", + "0037": "(0037)?[0-9]{7}-?[0-9][0-9A-Z]{0,5}", + "0060": "[0-9]{9}", + "0088": "[0-9]{13}", + "0096": "[0-9]{17}", + "0097": "[0-9]{11,16}", + "0106": "[0-9]{17}", + "0130": ".*", + "0135": ".*", + "0142": ".*", + "0151": "[0-9]{11}", + "0183": "CHE[0-9]{9}", + "0184": "DK[0-9]{8}([0-9]{2})?", + "0188": ".*", + "0190": "[0-9]{20}", + "0191": "[1789][0-9]{7}", + "0192": "[0-9]{9}", + "0193": ".{4,50}", + "0195": "[a-z]{2}[a-z]{3}([0-9]{8}|[0-9]{9}|[RST][0-9]{2}[a-z]{2}[0-9]{4})[0-9a-z]", + "0196": "[0-9]{10}", + "0198": "DK[0-9]{8}", + "0199": "[A-Z0-9]{18}[0-9]{2}", + "0020": "[0-9]{9}", + "0201": "[0-9a-zA-Z]{6}", + "0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}", + "0208": "0[0-9]{9}", + "0209": ".*", + "0210": "[A-Z0-9]+", + "0211": "IT[0-9]{11}", + "0212": "[0-9]{7}-[0-9]", + "0213": "FI[0-9]{8}", + "0205": "[A-Z0-9]+", + "0221": "T[0-9]{13}", + "0230": ".*", + "9901": ".*", + "9902": "[1-9][0-9]{7}", + "9904": "DK[0-9]{8}", + "9909": "NO[0-9]{9}MVA", + "9910": "HU[0-9]{8}", + "9912": "[A-Z]{2}[A-Z0-9]{,20}", + "9913": ".*", + "9914": "ATU[0-9]*", + "9915": "[A-Z][A-Z0-9]*", + "9916": ".*", + "9917": "[0-9]{10}", + "9918": "[A-Z]{2}[0-9]{2}[A-Z-0-9]{11,30}", + "9919": "[A-Z][0-9]{3}[A-Z][0-9]{3}[A-Z]", + "9920": ".*", + "9921": ".*", + "9922": ".*", + "9923": ".*", + "9924": ".*", + "9925": ".*", + "9926": ".*", + "9927": ".*", + "9928": ".*", + "9929": ".*", + "9930": ".*", + "9931": ".*", + "9932": ".*", + "9933": ".*", + "9934": ".*", + "9935": ".*", + "9936": ".*", + "9937": ".*", + "9938": ".*", + "9939": ".*", + "9940": ".*", + "9941": ".*", + "9942": ".*", + "9943": ".*", + "9944": ".*", + "9945": ".*", + "9946": ".*", + "9947": ".*", + "9948": ".*", + "9949": ".*", + "9950": ".*", + "9951": ".*", + "9952": ".*", + "9953": ".*", + "9954": ".*", + "9956": "0[0-9]{9}", + "9957": ".*", + "9959": ".*", + } + + def __call__(self, value): + if ":" not in value: + raise ValidationError(_("A PEPPOL participant ID always starts with a prefix, followed by a colon (:).")) + + prefix, second = value.split(":", 1) + if prefix not in self.regex_rules: + raise ValidationError(_("The PEPPOL participant ID prefix %(number)s is not known to our system. Please " + "reach out to us if you are sure this ID is correct."), params={"number": prefix}) + + if not re.match(self.regex_rules[prefix], second): + raise ValidationError(_("The PEPPOL participant ID does not match the validation rules for the prefix " + "%(number)s. Please reach out to us if you are sure this ID is correct."), + params={"number": prefix}) + return value + + +@transmission_types.new() +class PeppolTransmissionType(TransmissionType): + identifier = "peppol" + verbose_name = "PEPPOL" + priority = 250 + enforce_transmission = True + + def is_available(self, event, country: Country, is_business: bool): + return is_business and super().is_available(event, country, is_business) + + @property + def invoice_address_form_fields(self) -> dict: + return { + "transmission_peppol_participant_id": forms.CharField( + label=_("PEPPOL participant ID"), + validators=[ + PeppolIdValidator(), + ] + ), + } + + def invoice_address_form_fields_required(self, country: Country, is_business: bool): + base = { + "company", "street", "zipcode", "city", "country", + } + return base | {"transmission_peppol_participant_id"} diff --git a/src/pretix/base/invoicing/transmission.py b/src/pretix/base/invoicing/transmission.py new file mode 100644 index 0000000000..fd31335698 --- /dev/null +++ b/src/pretix/base/invoicing/transmission.py @@ -0,0 +1,246 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from typing import Optional + +from django_countries.fields import Country + +from pretix.base.models import Invoice, InvoiceAddress +from pretix.base.signals import EventPluginRegistry, Registry + + +class TransmissionType: + @property + def identifier(self) -> str: + """ + A short and unique identifier for this transmission type. + """ + raise NotImplementedError + + @property + def verbose_name(self) -> str: + """ + A human-readable name for this transmission type to be shown internally in the backend. + """ + raise NotImplementedError + + @property + def public_name(self) -> str: + """ + A human-readable name for this transmission type to be shown to the public. + By default, this is the same as ``verbose_name`` + """ + return self.verbose_name + + @property + def priority(self) -> int: + """ + Returns a priority that is used for sorting transmission type. Higher priority means higher up in the list. + Default to 100. Providers with same priority are sorted alphabetically. + """ + return 100 + + @property + def exclusive(self) -> bool: + """ + If a transmission type is exclusive, no other type can be chosen if this type is + available. Use e.g. if a certain transmission type is legally required in a certain + jurisdiction. + """ + return False + + @property + def enforce_transmission(self) -> bool: + """ + If a transmission type enforces transmission, every invoice created with this type will be transferred. + If not, the backend user is in some cases trusted to decide whether or not to transmit it. + """ + return False + + def is_available(self, event, country: Country, is_business: bool) -> bool: + providers = transmission_providers.filter(type=self.identifier, active_in=event) + return any( + provider.is_available(event, country, is_business) + for provider, _ in providers + ) + + def invoice_address_form_fields_required(self, country: Country, is_business: bool): + return set() + + def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set: + return set(self.invoice_address_form_fields.keys()) + + def validate_address(self, ia: InvoiceAddress): + pass + + @property + def invoice_address_form_fields(self) -> dict: + """ + Return a set of form fields that **must** be prefixed with ``transmission__``. + """ + return {} + + def form_data_to_transmission_info(self, form_data: dict) -> dict: + return form_data + + def transmission_info_to_form_data(self, transmission_info: dict) -> dict: + return transmission_info + + +class TransmissionProvider: + """ + Base class for a transmission provider. Should NOT hold internal state as the class is only + instantiated once and then shared between events and organizers. + """ + + @property + def identifier(self): + """ + A short and unique identifier for this transmission provider. + This should only contain lowercase letters and underscores. + """ + raise NotImplementedError + + @property + def type(self): + """ + Identifier of the transmission type this provider provides. + """ + raise NotImplementedError + + @property + def verbose_name(self): + """ + A human-readable name for this transmission provider (can be localized). + """ + raise NotImplementedError + + @property + def testmode_supported(self) -> bool: + """ + Whether testmode invoices may be passed to this provider. + """ + return False + + def is_ready(self, event) -> bool: + """ + Return whether this provider has all required configuration to be used in this event. + """ + raise NotImplementedError + + def is_available(self, event, country: Country, is_business: bool) -> bool: + """ + Return whether this provider may be used for an invoice for the given recipient country and address type. + """ + raise NotImplementedError + + def transmit(self, invoice: Invoice): + """ + Transmit the invoice. The invoice passed as a parameter will be in status ``TRANSMISSION_STATUS_INFLIGHT``. + Invoices that stay in this state for more than 24h will be retried automatically. Implementations are expected to: + + - Send the invoice. + + - Update the ``transmission_status`` to `TRANSMISSION_STATUS_COMPLETED` or `TRANSMISSION_STATUS_FAILED` + after sending, as well as ``transmission_info`` with provider-specific data, and ``transmission_date`` to + the date and time of completion. + + - Create a log entry of action type ``pretix.event.order.invoice.sent`` or + ``pretix.event.order.invoice.sending_failed`` with the fields ``full_invoice_no``, ``transmission_provider``, + ``transmission_type`` and a provider-specific ``data`` field. + + Make sure to either handle ``invoice.order.testmode`` properly or set ``testmode_supported`` to ``False``. + """ + raise NotImplementedError + + @property + def priority(self) -> int: + """ + Returns a priority that is used for sorting transmission providers. Higher priority will be chosen over + lower priority for transmission. Default to 100. + """ + return 100 + + def settings_url(self, event) -> Optional[str]: + """ + Return a URL to the settings page of this provider (if any). + """ + return None + + +class TransmissionProviderRegistry(EventPluginRegistry): + def __init__(self): + super().__init__({ + 'identifier': lambda o: getattr(o, 'identifier'), + 'type': lambda o: getattr(o, 'type'), + }) + + def register(self, *objs): + for obj in objs: + if not isinstance(obj, TransmissionProvider): + raise TypeError('Entries must be derived from TransmissionProvider') + + if obj.type == "email" and not obj.__module__.startswith('pretix.base.'): + raise TypeError('No custom providers for email allowed') + + return super().register(*objs) + + +class TransmissionTypeRegistry(Registry): + def __init__(self): + super().__init__({ + 'identifier': lambda o: getattr(o, 'identifier'), + }) + + def register(self, *objs): + for obj in objs: + if not isinstance(obj, TransmissionType): + raise TypeError('Entries must be derived from TransmissionType') + + if not obj.__module__.startswith('pretix.base.'): + raise TypeError('Plugins are currently not allowed to add transmission types') + + return super().register(*objs) + + +""" +Registry for transmission providers. + +Each entry in this registry should be an instance of a subclass of ``TransmissionProvider``. +They are annotated with their ``identifier``, ``type``, and the defining ``plugin``. +""" +transmission_providers = TransmissionProviderRegistry() + + +""" +Registry for transmission types. + +Each entry in this registry should be an instance of a subclass of ``TransmissionType``. +They are annotated with their ``identifier``. +""" +transmission_types = TransmissionTypeRegistry() + + +def get_transmission_types(): + return sorted( + transmission_types.registered_entries.keys(), + key=lambda t: (-t.priority, str(t.public_name)), + ) diff --git a/src/pretix/base/migrations/0288_invoice_transmission.py b/src/pretix/base/migrations/0288_invoice_transmission.py new file mode 100644 index 0000000000..adc4569d93 --- /dev/null +++ b/src/pretix/base/migrations/0288_invoice_transmission.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.17 on 2025-04-21 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0287_organizer_plugins"), + ] + + operations = [ + migrations.RenameField( + model_name="invoice", + old_name="sent_to_customer", + new_name="transmission_date", + ), + migrations.AddField( + model_name="invoice", + name="invoice_to_transmission_info", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="invoice", + name="transmission_info", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="invoice", + name="transmission_provider", + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name="invoice", + name="transmission_status", + field=models.CharField(default="unknown", max_length=255), + ), + migrations.AddField( + model_name="invoice", + name="created", + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name="invoice", + name="invoice_to_is_business", + field=models.BooleanField(null=True), + ), + migrations.RunSQL( + "UPDATE pretixbase_invoice SET transmission_status = 'completed' WHERE transmission_date IS NOT NULL", + migrations.RunSQL.noop, + ), + migrations.AddField( + model_name="invoice", + name="transmission_type", + field=models.CharField(default="email", max_length=255), + ), + migrations.AddField( + model_name="invoiceaddress", + name="transmission_info", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="invoiceaddress", + name="transmission_type", + field=models.CharField(default="email", max_length=255), + ), + migrations.RunSQL( + "UPDATE pretixbase_event_settingsstore SET key = 'mail_text_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_text'", + "UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_text' WHERE key = 'mail_text_order_invoice'", + ), + migrations.RunSQL( + "UPDATE pretixbase_event_settingsstore SET key = 'mail_subject_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_subject'", + "UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_subject' WHERE key = 'mail_subject_order_invoice'", + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 6919f44e0b..5f4de4b8b7 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -42,7 +42,7 @@ 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.utils.translation import gettext_lazy as _, pgettext from django_scopes import ScopedManager from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS @@ -110,6 +110,21 @@ class Invoice(models.Model): :param file: The filename of the rendered invoice :type file: File """ + TRANSMISSION_STATUS_PENDING = "pending" + TRANSMISSION_STATUS_INFLIGHT = "inflight" + TRANSMISSION_STATUS_COMPLETED = "completed" + TRANSMISSION_STATUS_FAILED = "failed" + TRANSMISSION_STATUS_UNKNOWN = "unknown" + TRANSMISSION_STATUS_TESTMODE_IGNORED = "testmode_ignored" + TRANSMISSION_STATUS_CHOICES = ( + (TRANSMISSION_STATUS_PENDING, _("pending transmission")), + (TRANSMISSION_STATUS_INFLIGHT, _("currently being transmitted")), + (TRANSMISSION_STATUS_COMPLETED, _("transmitted")), + (TRANSMISSION_STATUS_FAILED, _("failed")), + (TRANSMISSION_STATUS_UNKNOWN, _("unknown")), + (TRANSMISSION_STATUS_TESTMODE_IGNORED, _("not transmitted due to test mode")), + ) + 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) @@ -131,6 +146,7 @@ class Invoice(models.Model): invoice_to = models.TextField() invoice_to_company = models.TextField(null=True) + invoice_to_is_business = models.BooleanField(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) @@ -139,9 +155,11 @@ class Invoice(models.Model): invoice_to_country = FastCountryField(null=True) invoice_to_vat_id = models.TextField(null=True) invoice_to_beneficiary = models.TextField(null=True) + invoice_to_transmission_info = models.JSONField(null=True, blank=True) internal_reference = models.TextField(blank=True) custom_field = models.CharField(max_length=255, null=True) + created = models.DateTimeField(auto_now_add=True, null=True) # null for backwards compatibility date = models.DateField(default=today) locale = models.CharField(max_length=50, default='en') introductory_text = models.TextField(blank=True) @@ -158,14 +176,28 @@ class Invoice(models.Model): shredded = models.BooleanField(default=False) - # The field sent_to_organizer records whether this invocie was already sent to the organizer by a configured + # The field sent_to_organizer records whether this invoice was already sent to the organizer by a configured # mechanism such as email. # NULL: The cronjob that handles sending did not yet run. # True: The invoice was sent. # False: The invoice wasn't sent and never will, because sending was not configured at the time of the check. sent_to_organizer = models.BooleanField(null=True, blank=True) - sent_to_customer = models.DateTimeField(null=True, blank=True) + transmission_type = models.CharField( + max_length=255, + default="email", + ) + transmission_provider = models.CharField( + max_length=255, + null=True, blank=True, + ) + transmission_status = models.CharField( + max_length=255, + choices=TRANSMISSION_STATUS_CHOICES, + default=TRANSMISSION_STATUS_UNKNOWN, + ) + transmission_date = models.DateTimeField(null=True, blank=True) + transmission_info = models.JSONField(null=True, blank=True) file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255) @@ -323,6 +355,19 @@ class Invoice(models.Model): def __str__(self): return self.full_invoice_no + @property + def regenerate_allowed(self): + return self.transmission_status in ( + Invoice.TRANSMISSION_STATUS_UNKNOWN, + Invoice.TRANSMISSION_STATUS_PENDING, + Invoice.TRANSMISSION_STATUS_FAILED, + ) and self.event.settings.invoice_regenerate_allowed + + @property + def transmission_type_instance(self): + from pretix.base.invoicing.transmission import transmission_types + return transmission_types.get(identifier=self.transmission_type)[0] + class InvoiceLine(models.Model): """ diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index c058128b9d..698a0a602c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1939,6 +1939,7 @@ class OrderPayment(models.Model): ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True): from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, + invoice_transmission_separately, transmit_invoice, ) from pretix.base.services.locking import LOCK_TRUST_WINDOW @@ -1969,13 +1970,19 @@ class OrderPayment(models.Model): trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment ) + transmit_invoice_task = invoice_transmission_separately(invoice) + transmit_invoice_mail = not transmit_invoice_task and self.order.event.settings.invoice_email_attachment and self.order.email + if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid: - self._send_paid_mail(invoice, user, mail_text) + self._send_paid_mail(invoice if transmit_invoice_mail else None, user, mail_text) if self.order.event.settings.mail_send_order_paid_attendee: for p in self.order.positions.all(): if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email: self._send_paid_mail_attendee(p, user) + if invoice and not transmit_invoice_mail: + transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False)) + def _send_paid_mail_attendee(self, position, user): from pretix.base.services.mail import SendMailException @@ -2005,7 +2012,7 @@ class OrderPayment(models.Model): self.order.send_mail( email_subject, email_template, email_context, 'pretix.event.order.email.order_paid', user, - invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [], + invoices=[invoice] if invoice else [], attach_tickets=True, attach_ical=self.order.event.settings.mail_attach_ical ) @@ -3293,6 +3300,9 @@ class InvoiceAddress(models.Model): blank=True ) + transmission_type = models.CharField(max_length=255, default="email") + transmission_info = models.JSONField(null=True, blank=True) + objects = ScopedManager(organizer='order__event__organizer') profiles = ScopedManager(organizer='customer__organizer') @@ -3344,6 +3354,7 @@ class InvoiceAddress(models.Model): self.internal_reference, (_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '', ] + parts += [f'{k}: {v}' for k, v in self.describe_transmission()] return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()]) @property @@ -3398,9 +3409,28 @@ class InvoiceAddress(models.Model): 'custom_field': self.custom_field, 'internal_reference': self.internal_reference, 'beneficiary': self.beneficiary, + 'transmission_type': self.transmission_type, + **self.transmission_info, }) return d + def describe_transmission(self): + from pretix.base.invoicing.transmission import transmission_types + data = [] + + t, __ = transmission_types.get(identifier=self.transmission_type) + data.append((_("Transmission type"), t.public_name)) + form_data = t.transmission_info_to_form_data(self.transmission_info or {}) + for k, f in t.invoice_address_form_fields.items(): + v = form_data.get(k) + if v is True: + v = _("Yes") + elif v is False: + v = _("No") + if v: + data.append((f.label, v)) + return data + def cachedticket_name(instance, filename: str) -> str: secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index ce612003ca..85a69e9e73 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -51,11 +51,16 @@ from django_scopes import scope, scopes_disabled from i18nfield.strings import LazyI18nString from pretix.base.i18n import language +from pretix.base.invoicing.transmission import ( + get_transmission_types, transmission_providers, +) from pretix.base.models import ( ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ) from pretix.base.models.tax import EU_CURRENCIES -from pretix.base.services.tasks import TransactionAwareTask +from pretix.base.services.tasks import ( + TransactionAwareProfiledEventTask, TransactionAwareTask, +) from pretix.base.signals import invoice_line_text, periodic_task from pretix.celery_app import app from pretix.helpers.database import OF_SELF, rolledback_transaction @@ -71,12 +76,13 @@ def _location_oneliner(loc): @transaction.atomic def build_invoice(invoice: Invoice) -> Invoice: invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale) + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING if invoice.locale == '__user__': invoice.locale = invoice.order.locale or invoice.event.settings.locale lp = invoice.order.payments.last() - with language(invoice.locale, invoice.event.settings.region): + with (language(invoice.locale, invoice.event.settings.region)): invoice.invoice_from = invoice.event.settings.get('invoice_address_from') invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name') invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') @@ -127,6 +133,7 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.internal_reference = ia.internal_reference invoice.custom_field = ia.custom_field invoice.invoice_to_company = ia.company + invoice.invoice_to_is_business = ia.is_business invoice.invoice_to_name = ia.name invoice.invoice_to_street = ia.street invoice.invoice_to_zipcode = ia.zipcode @@ -134,6 +141,8 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.invoice_to_country = ia.country invoice.invoice_to_state = ia.state invoice.invoice_to_beneficiary = ia.beneficiary + invoice.invoice_to_transmission_info = ia.transmission_info or {} + invoice.transmission_type = ia.transmission_type if ia.vat_id: invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id @@ -356,7 +365,9 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True): cancellation.payment_provider_stamp = '' cancellation.file = None cancellation.sent_to_organizer = None - cancellation.sent_to_customer = None + cancellation.transmission_provider = None + cancellation.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING + cancellation.transmission_date = None with language(invoice.locale, invoice.event.settings.region): cancellation.invoice_from = invoice.event.settings.get('invoice_address_from') cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name') @@ -512,6 +523,36 @@ def build_preview_invoice_pdf(event): return event.invoice_renderer.generate(invoice) +def order_invoice_transmission_separately(order): + try: + info = order.invoice_address.transmission_info or {} + return ( + order.invoice_address.transmission_type != "email" or + ( + info.get("transmission_email_address") and + order.email != info["transmission_email_address"] + ) + ) + except InvoiceAddress.DoesNotExist: + return False + + +def invoice_transmission_separately(invoice): + if not invoice: + return False + try: + info = invoice.invoice_to_transmission_info or {} + return ( + invoice.transmission_type != "email" or + ( + info.get("transmission_email_address") and + invoice.order.email != info["transmission_email_address"] + ) + ) + except InvoiceAddress.DoesNotExist: + return False + + @receiver(signal=periodic_task) @scopes_disabled() def send_invoices_to_organizer(sender, **kwargs): @@ -551,3 +592,124 @@ def send_invoices_to_organizer(sender, **kwargs): else: i.sent_to_organizer = False i.save(update_fields=['sent_to_organizer']) + + +@receiver(signal=periodic_task) +@scopes_disabled() +def retry_stuck_invoices(sender, **kwargs): + with transaction.atomic(): + qs = Invoice.objects.filter( + transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT, + transmission_date__lte=now() - timedelta(hours=24), + ).select_for_update( + of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked + ) + batch_size = 5000 + for invoice in qs[:batch_size]: + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, True)) + + +@receiver(signal=periodic_task) +@scopes_disabled() +def send_pending_invoices(sender, **kwargs): + with transaction.atomic(): + # Transmit all invoices that have not been transmitted by another process if the provider enforces + # transmission + types = [ + tt.identifier for tt in get_transmission_types() + if tt.enforce_transmission + ] + qs = Invoice.objects.filter( + transmission_type__in=types, + transmission_status=Invoice.TRANSMISSION_STATUS_PENDING, + created__lte=now() - timedelta(minutes=15), + ).select_for_update( + of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked + ) + batch_size = 5000 + for invoice in qs[:batch_size]: + transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, False)) + + +@app.task(base=TransactionAwareProfiledEventTask) +def transmit_invoice(sender, invoice_id, allow_retransmission=True, **kwargs): + with transaction.atomic(durable='tests.testdummy' not in settings.INSTALLED_APPS): + # We need durable=True for transactional correctness, but can't have it during tests + invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice_id) + + if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT: + logger.info(f"Did not transmit invoice {invoice.pk} due to being in inflight state.") + return + + if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING and not allow_retransmission: + logger.info(f"Did not transmit invoice {invoice.pk} due to status being {invoice.transmission_status}.") + return + + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_INFLIGHT + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + + providers = sorted([ + provider + for provider, __ in transmission_providers.filter(type=invoice.transmission_type, active_in=sender) + ], key=lambda p: (-p.priority, p.identifier)) + + provider = None + for p in providers: + if p.is_available(sender, invoice.invoice_to_country, invoice.invoice_to_is_business): + provider = p + break + + if not provider: + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + invoice.order.log_action( + "pretix.event.order.invoice.sending_failed", + data={ + "full_invoice_no": invoice.full_invoice_no, + "transmission_provider": None, + "transmission_type": invoice.transmission_type, + "data": { + "reason": "no_provider", + }, + } + ) + return + + if invoice.order.testmode and not provider.testmode_supported: + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_TESTMODE_IGNORED + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + invoice.order.log_action( + "pretix.event.order.invoice.testmode_ignored", + data={ + "full_invoice_no": invoice.full_invoice_no, + "transmission_provider": None, + "transmission_type": invoice.transmission_type, + } + ) + return + + try: + provider.transmit(invoice) + except Exception as e: + logger.exception(f"Transmission of invoice {invoice.pk} failed with exception.") + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + invoice.order.log_action( + "pretix.event.order.invoice.sending_failed", + data={ + "full_invoice_no": invoice.full_invoice_no, + "transmission_provider": None, + "transmission_type": invoice.transmission_type, + "data": { + "reason": "exception", + "exception": str(e), + }, + } + ) diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 73cfedbbd4..4448e28aa6 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -658,8 +658,55 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st raise SendMailException('Failed to send an email to {}.'.format(to)) else: for i in invoices_sent: - i.sent_to_customer = now() - i.save(update_fields=['sent_to_customer']) + if i.transmission_type == "email": + # Mark invoice as sent when it was sent to the requested address *either* at the time of invoice + # creation *or* as of right now. + expected_recipients = [ + (i.invoice_to_transmission_info or {}).get("transmission_email_address") or i.order.email, + ] + try: + expected_recipients.append(order.invoice_address.transmission_info.get("transmission_email_address") or i.order.email) + except InvoiceAddress.DoesNotExist: + pass + if not any(t in expected_recipients for t in to): + continue + if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED: + i.transmission_date = now() + i.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED + i.transmission_provider = "email_pdf" + i.transmission_info = { + "sent": [ + { + "recipients": to, + "datetime": now().isoformat(), + } + ] + } + i.save(update_fields=[ + "transmission_date", "transmission_provider", "transmission_status", + "transmission_info" + ]) + elif i.transmission_provider == "email_pdf": + i.transmission_info["sent"].append( + { + "recipients": to, + "datetime": now().isoformat(), + } + ) + i.save(update_fields=[ + "transmission_info" + ]) + i.order.log_action( + "pretix.event.order.invoice.sent", + data={ + "full_invoice_no": i.full_invoice_no, + "transmission_provider": "email_pdf", + "transmission_type": "email", + "data": { + "recipients": [to], + }, + } + ) def mail_send(*args, **kwargs): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 200b8a9f24..3190848160 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -84,6 +84,8 @@ from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, + invoice_transmission_separately, order_invoice_transmission_separately, + transmit_invoice, ) from pretix.base.services.locking import ( LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects, @@ -389,13 +391,19 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False order_approved.send(order.event, order=order) invoice = order.invoices.last() # Might be generated by plugin already + + transmit_invoice_task = order_invoice_transmission_separately(order) + transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email + if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): if not invoice: invoice = generate_invoice( order, - trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email + # send_mail will trigger PDF generation later + trigger_pdf=not transmit_invoice_mail ) - # send_mail will trigger PDF generation later + if transmit_invoice_task: + transmit_invoice.apply_async(args=(order.event_id, invoice.pk, False)) if send_mail: with language(order.locale, order.event.settings.region): @@ -423,7 +431,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False order.total == Decimal('0.00') or order.valid_if_pending ), - invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else [] + invoices=[invoice] if invoice and transmit_invoice_mail else [] ) except SendMailException: logger.exception('Order approved email could not be sent') @@ -619,6 +627,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.create_transactions() + transmit_invoices_task = [i for i in invoices if invoice_transmission_separately(i)] + transmit_invoices_mail = [i for i in invoices if i not in transmit_invoices_task and order.event.settings.invoice_email_attachment] + for i in transmit_invoices_task: + transmit_invoice.apply_async(args=(order.event_id, i.pk, False)) + if send_mail: with language(order.locale, order.event.settings.region): email_template = order.event.settings.mail_text_order_canceled @@ -628,7 +641,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.send_mail( email_subject, email_template, email_context, 'pretix.event.order.email.order_canceled', user, - invoices=invoices if order.event.settings.invoice_email_attachment else [] + invoices=transmit_invoices_mail, ) except SendMailException: logger.exception('Order canceled email could not be sent') @@ -1085,11 +1098,12 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no def _order_placed_email(event: Event, order: Order, email_template, subject_template, log_entry: str, invoice, payments: List[OrderPayment], is_free=False): email_context = get_email_context(event=event, order=order, payments=payments) + try: order.send_mail( subject_template, email_template, email_context, log_entry, - invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [], + invoices=[invoice] if invoice else [], attach_tickets=True, attach_ical=event.settings.mail_attach_ical and ( not event.settings.mail_attach_ical_paid_only or @@ -1278,6 +1292,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis not order.require_approval ) + transmit_invoice_task = order_invoice_transmission_separately(order) + transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email + invoice = order.invoices.last() # Might be generated by plugin already if not invoice and invoice_qualified(order): invoice_required = ( @@ -1291,9 +1308,11 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis if invoice_required: invoice = generate_invoice( order, - trigger_pdf=not event.settings.invoice_email_attachment or not order.email + # send_mail will trigger PDF generation later + trigger_pdf=not transmit_invoice_mail ) - # send_mail will trigger PDF generation later + if transmit_invoice_task: + transmit_invoice.apply_async(args=(event.pk, invoice.pk, False)) if order.email: if order.require_approval: @@ -1320,8 +1339,16 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis subject_attendees_template = event.settings.mail_subject_order_placed_attendee if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid: - _order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs, - is_free=free_order_flow) + _order_placed_email( + event, + order, + email_template, + subject_template, + log_entry, + invoice if transmit_invoice_mail else None, + payment_objs, + is_free=free_order_flow + ) if email_attendees: for p in order.positions.all(): if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: @@ -2924,17 +2951,36 @@ class OrderChangeManager: if self.split_order: self.split_order.create_transactions() + transmit_invoices_task = [i for i in self._invoices if invoice_transmission_separately(i)] + transmit_invoices_mail = [ + i for i in self._invoices + if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email + ] + + if self.split_order: + split_invoices = list(self.split_order.invoices.all()) + transmit_invoices_task += [ + i for i in split_invoices if invoice_transmission_separately(i) + ] + split_transmit_invoices_mail = [ + i for i in split_invoices + if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email + ] + if self.notify: notify_user_changed_order( self.order, self.user, self.auth, - self._invoices if self.event.settings.invoice_email_attachment else [] + transmit_invoices_mail, ) if self.split_order: notify_user_changed_order( self.split_order, self.user, self.auth, - list(self.split_order.invoices.all()) if self.event.settings.invoice_email_attachment else [] + split_transmit_invoices_mail, ) + for i in transmit_invoices_task: + transmit_invoice.apply_async(args=(self.event.pk, i.pk, False)) + order_changed.send(self.order.event, order=self.order) def _clear_tickets_cache(self): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 12074375cb..fbc52b31a9 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -2695,6 +2695,20 @@ You can change your order details and view the status of your order at {url} Best regards, +Your {event} team""")) # noqa: W291 + }, + 'mail_subject_order_invoice': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}")), + }, + 'mail_text_order_invoice': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("""Hello, + +please find attached a new invoice for order {code} for {event}. This order has been placed by {order_email}. + +Best regards, + Your {event} team""")) # noqa: W291 }, 'mail_days_download_reminder': { diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index bc0ea800a8..b1d22a0148 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -524,8 +524,11 @@ class InvoiceAddressShredder(BaseDataShredder): d = le.parsed_data if 'invoice_data' in d and not isinstance(d['invoice_data'], bool): for field in d['invoice_data']: - if d['invoice_data'][field]: - d['invoice_data'][field] = '█' + if d['invoice_data'][field] and field != "transmission_type": + if field == "transmission_info": + d['invoice_data'][field] = {"_shredded": True} + else: + d['invoice_data'][field] = '█' le.data = json.dumps(d) le.shredded = True le.save(update_fields=['data', 'shredded']) @@ -600,6 +603,7 @@ class InvoiceShredder(BaseDataShredder): i.additional_text = "█" i.invoice_to = "█" i.payment_provider_text = "█" + i.transmission_info = {"_shredded": True} i.save() i.lines.update(description="█") diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py index 303578ec78..693dc0b713 100644 --- a/src/pretix/base/views/js_helpers.py +++ b/src/pretix/base/views/js_helpers.py @@ -21,38 +21,107 @@ # import pycountry from django.http import JsonResponse +from django.shortcuts import get_object_or_404 from django.utils.translation import pgettext +from django_countries.fields import Country +from django_scopes import scope from pretix.base.addressvalidation import ( COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED, ) +from pretix.base.invoicing.transmission import get_transmission_types +from pretix.base.models import Organizer from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, ) -def states(request): - cc = request.GET.get("country", "DE") +def _info(cc): info = { - 'street': {'required': True}, - 'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, - 'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, + 'street': {'required': 'if_any'}, + 'zipcode': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False}, + 'city': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False}, 'state': { 'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, - 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, + 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False, 'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')), }, 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False}, } if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: - return JsonResponse({'data': [], **info, }) + return {'data': [], **info} types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] - return JsonResponse({ + return { 'data': [ {'name': s.name, 'code': s.code[3:]} for s in sorted(statelist, key=lambda s: s.name) ], **info, - }) + } + + +def address_form(request): + cc = request.GET.get("country", "DE") + info = _info(cc) + + if request.GET.get("invoice") == "true": + # Do not consider live=True, as this does not expose sensitive information and we also want it accessible + # from e.g. the backend when the event is not yet life. + organizer = get_object_or_404(Organizer, slug=request.GET.get("organizer")) + with (scope(organizer=organizer)): + event = get_object_or_404(organizer.events, slug=request.GET.get("event")) + country = Country(cc) + is_business = request.GET.get("is_business") == "business" + selected_transmission_type = request.GET.get("transmission_type") + transmission_type_required = request.GET.get("transmission_type_required") == "true" + + info["transmission_types"] = [] + + for t in get_transmission_types(): + if t.is_available(event=event, country=country, is_business=is_business): + result = {"name": str(t.public_name), "code": t.identifier} + if t.exclusive: + info["transmission_types"] = [result] + break + else: + info["transmission_types"].append(result) + + info["transmission_type"] = { + # Hide transmission type if email is the only type since that's basically the backwards-compatible + # option + "visible": [t["code"] for t in info["transmission_types"]] != ["email"], + } + if selected_transmission_type not in [t["code"] for t in info["transmission_types"]]: + if transmission_type_required: + # The previously selected transmission type is no longer selectable, e.g. because + # of a country change. To avoid a second roundtrip to this endpoint, let's show + # the fields as if the first remaining option were selected (which is what the client + # side will now do). + selected_transmission_type = info["transmission_types"][0]["code"] + else: + selected_transmission_type = "-" + + for transmission_type in get_transmission_types(): + required = transmission_type.invoice_address_form_fields_required( + country=country, + is_business=is_business + ) + visible = transmission_type.invoice_address_form_fields_visible( + country=country, + is_business=is_business + ) + if transmission_type.identifier == selected_transmission_type: + for k, v in info.items(): + if k in required: + v["required"] = True + if k in visible: + v["visible"] = True + for k, f in transmission_type.invoice_address_form_fields.items(): + info[k] = { + "visible": transmission_type.identifier == selected_transmission_type and k in visible, + "required": transmission_type.identifier == selected_transmission_type and k in required + } + + return JsonResponse(info) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 427447ab35..1b4f6cdc99 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1199,6 +1199,20 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): required=False, widget=I18nMarkdownTextarea, ) + mail_subject_order_invoice = I18nFormField( + label=_("Subject"), + required=False, + widget=I18nTextInput, + help_text=_("This will only be used if the invoice is sent to a different email address or at a different time " + "than the order confirmation."), + ) + mail_text_order_invoice = I18nFormField( + label=_("Text"), + required=False, + widget=I18nMarkdownTextarea, + help_text=_("This will only be used if the invoice is sent to a different email address or at a different time " + "than the order confirmation."), + ) mail_subject_download_reminder = I18nFormField( label=_("Subject sent to order contact address"), required=False, @@ -1350,6 +1364,8 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): 'mail_text_order_payment_failed': ['event', 'order'], 'mail_subject_order_payment_failed': ['event', 'order'], 'mail_text_order_custom_mail': ['event', 'order'], + 'mail_text_order_invoice': ['event', 'order', 'invoice'], + 'mail_subject_order_invoice': ['event', 'order', 'invoice'], 'mail_text_download_reminder': ['event', 'order'], 'mail_subject_download_reminder': ['event', 'order'], 'mail_text_download_reminder_attendee': ['event', 'order', 'position'], diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index ffbe1e3bb1..a7f9233d9a 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -524,6 +524,11 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl 'pretix.event.order.invoice.generated': _('The invoice has been generated.'), 'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'), 'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'), + 'pretix.event.order.invoice.sent': _('The invoice {full_invoice_no} has been sent.'), + 'pretix.event.order.invoice.sending_failed': _('The transmission of invoice {full_invoice_no} has failed.'), + 'pretix.event.order.invoice.testmode_ignored': _('Invoice {full_invoice_no} has not been transmitted because ' + 'no transmission provider supports test mode invoices.'), + 'pretix.event.order.invoice.retransmitted': _('The invoice {full_invoice_no} has been scheduled for retransmission.'), 'pretix.event.order.comment': _('The order\'s internal comment has been updated.'), 'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'), 'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been ' @@ -536,6 +541,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl 'pretix.event.order.email.error': _('Sending of an email has failed.'), 'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they ' 'would have been too large to be likely to arrive.'), + 'pretix.event.order.email.invoice': _('An invoice email has been sent.'), 'pretix.event.order.email.custom_sent': _('A custom email has been sent.'), 'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'), 'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket ' diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index a3f8b89965..d2b21e70ec 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -1,6 +1,7 @@ {% extends "pretixcontrol/event/settings_base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load getitem %} {% block inside %}

{% trans "Invoice settings" %}

@@ -59,6 +60,99 @@ {% bootstrap_field form.invoice_renderer_highlight_order_code layout="control" %} {% bootstrap_field form.invoice_eu_currencies layout="control" %} +
+ {% trans "Invoice transmission" %} +

+ {% blocktrans trimmed %} + pretix can transmit invoices using different transmission methods. Different transmission methods + might be required depending on country and industry. By default, sending invoices as PDF files + via email is always available. Other types of transmission can be added by plugins. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + Whether a transmission method listed here is actually selectable for customers may depend on + the country of the customer or whether the customer is entering a business address. + {% endblocktrans %} +

+
+ + + + + + + + + {% for t in transmission_types %} + {% if transmission_providers|getitem:t.identifier %} + + + + + + + + + {% for p, is_ready, settings_url in transmission_providers|getitem:t.identifier %} + + + + + + {% endfor %} + + {% endif %} + {% endfor %} +
{% trans "Transmission method" %}{% trans "Status" %}
+ {{ t.verbose_name }} + + {% if ready|getitem:t.identifier %} + + + {% trans "Available" %} + {% if t.exclusive %} + + {% trans "(exclusive)" %} + + {% endif %} + + {% else %} + + + {% trans "Unavailable" %} + + {% endif %} +
+ {{ p.verbose_name }} + + {% if is_ready %} + + + {% trans "Available" %} + + {% else %} + + + {% trans "Not configured" %} + + {% endif %} + + {% if settings_url %} + + + {% trans "Settings" %} + + {% endif %} +
+
+

+ {% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %} + + {% trans "Enable additional invoice transmission plugins" %} + +

+
+ {% endif %} {% if not i.canceled %} - {% if request.event.settings.invoice_regenerate_allowed %} + {% if i.regenerate_allowed %}
{% csrf_token %} @@ -320,12 +366,6 @@
{% endif %} {% endfor %} - {% if invoices_send_link %} -
- - {% trans "Email invoices" %} - - {% endif %} {% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
{{ request.event.settings.invoice_address_custom_field }}
{{ order.invoice_address.custom_field }}
{% endif %} -
{% trans "Internal reference" %}
-
{{ order.invoice_address.internal_reference }}
+ {% if order.invoice_address.internal_reference %} +
{% trans "Internal reference" %}
+
{{ order.invoice_address.internal_reference }}
+ {% endif %} + {% for k, v in order.invoice_address.describe_transmission %} +
{{ k }}
+
{{ v }}
+ {% endfor %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 9fceaf6320..4243ad2d54 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -383,6 +383,8 @@ urlpatterns = [ name='event.order.geninvoice'), re_path(r'^orders/(?P[0-9A-Z]+)/invoices/(?P\d+)/regenerate$', orders.OrderInvoiceRegenerate.as_view(), name='event.order.regeninvoice'), + re_path(r'^orders/(?P[0-9A-Z]+)/invoices/(?P\d+)/retransmit$', orders.OrderInvoiceRetransmit.as_view(), + name='event.order.retransmitinvoice'), re_path(r'^orders/(?P[0-9A-Z]+)/invoices/(?P\d+)/reissue$', orders.OrderInvoiceReissue.as_view(), name='event.order.reissueinvoice'), re_path(r'^orders/(?P[0-9A-Z]+)/download/(?P\d+)/(?P[^/]+)/$', diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 9f3f7cdb8a..038fd7403c 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -37,7 +37,7 @@ import json import logging import operator import re -from collections import OrderedDict +from collections import OrderedDict, defaultdict from decimal import Decimal from io import BytesIO from itertools import groupby @@ -76,6 +76,9 @@ from i18nfield.utils import I18nJSONEncoder from pretix.base.email import get_available_placeholders from pretix.base.forms import PlaceholderValidator +from pretix.base.invoicing.transmission import ( + get_transmission_types, transmission_providers, +) from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher from pretix.base.models.event import EventMetaValue from pretix.base.services import tickets @@ -651,6 +654,22 @@ class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView): template_name = 'pretixcontrol/event/invoicing.html' permission = 'can_change_event_settings' + def get_context_data(self, **kwargs): + types = get_transmission_types() + providers = defaultdict(list) + ready = defaultdict(lambda: False) + for p, __ in transmission_providers.filter(active_in=self.request.event): + is_ready_result = p.is_ready(self.request.event) + providers[p.type].append((p, is_ready_result, p.settings_url(self.request.event))) + ready[p.type] = ready[p.type] or is_ready_result + for k, v in providers.items(): + v.sort(key=lambda p: (-p[0].priority, p[0].identifier)) + return super().get_context_data( + transmission_providers=providers, + transmission_types=types, + ready=ready, + ) + def get_success_url(self) -> str: if 'preview' in self.request.POST: return reverse('control:event.settings.invoice.preview', kwargs={ diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 7e3d893b6d..8785f5bd7e 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -66,7 +66,7 @@ from django.utils.html import conditional_escape, escape from django.utils.http import url_has_allowed_host_and_scheme from django.utils.safestring import mark_safe from django.utils.timezone import make_aware, now -from django.utils.translation import gettext, gettext_lazy as _, ngettext +from django.utils.translation import gettext, gettext_lazy as _ from django.views.generic import ( DetailView, FormView, ListView, TemplateView, View, ) @@ -93,7 +93,7 @@ from pretix.base.services.cancelevent import cancel_event from pretix.base.services.export import export, scheduled_event_export from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task, - invoice_qualified, regenerate_invoice, + invoice_qualified, regenerate_invoice, transmit_invoice, ) from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import ( @@ -551,27 +551,6 @@ class OrderDetail(OrderView): ctx['payment_refund_sum'] = self.order.payment_refund_sum ctx['pending_sum'] = self.order.pending_sum - unsent_invoices = [ii.pk for ii in ctx['invoices'] if not ii.sent_to_customer] - if unsent_invoices: - with language(self.order.locale): - ctx['invoices_send_link'] = reverse('control:event.order.sendmail', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - 'code': self.order.code - }) + '?' + urlencode({ - 'subject': ngettext('Your invoice', 'Your invoices', len(unsent_invoices)), - 'message': ngettext( - 'Hello,\n\nplease find your invoice attached to this email.\n\n' - 'Your {event} team', - 'Hello,\n\nplease find your invoices attached to this email.\n\n' - 'Your {event} team', - len(unsent_invoices) - ).format( - event="{event}", - ), - 'attach_invoices': unsent_invoices - }, doseq=True) - return ctx @cached_property @@ -1681,6 +1660,8 @@ class OrderInvoiceRegenerate(OrderView): else: if not inv.event.settings.invoice_regenerate_allowed: messages.error(self.request, _('Invoices may not be changed after they are created.')) + elif not inv.regenerate_allowed: + messages.error(self.request, _('Invoices may not be changed after they are transmitted.')) if inv.canceled: messages.error(self.request, _('The invoice has already been canceled.')) elif inv.sent_to_organizer: @@ -1701,6 +1682,37 @@ class OrderInvoiceRegenerate(OrderView): return HttpResponseNotAllowed(['POST']) +class OrderInvoiceRetransmit(OrderView): + permission = 'can_change_orders' + + def post(self, *args, **kwargs): + with transaction.atomic(durable=True): + try: + invoice = self.order.invoices.select_for_update(of=OF_SELF).get(pk=kwargs.get("id")) + except Invoice.DoesNotExist: + messages.error(self.request, _('Unknown invoice.')) + return redirect(self.get_order_url()) + + if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT: + messages.error(self.request, _('The invoice is currently being transmitted. You can start a new attempt after ' + 'the current one has been completed.')) + return redirect(self.get_order_url()) + + invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING + invoice.transmission_date = now() + invoice.save(update_fields=["transmission_status", "transmission_date"]) + messages.success(self.request, _('The invoice has been scheduled for retransmission.')) + self.order.log_action('pretix.event.order.invoice.retransmitted', user=self.request.user, data={ + 'invoice': invoice.pk, + 'full_invoice_no': invoice.full_invoice_no, + }) + transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True)) + return redirect(self.get_order_url()) + + def get(self, *args, **kwargs): # NOQA + return HttpResponseNotAllowed(['POST']) + + class OrderInvoiceReissue(OrderView): permission = 'can_change_orders' diff --git a/src/pretix/locale/de/LC_MESSAGES/django.po b/src/pretix/locale/de/LC_MESSAGES/django.po index 98947832e9..053cf8fa7b 100644 --- a/src/pretix/locale/de/LC_MESSAGES/django.po +++ b/src/pretix/locale/de/LC_MESSAGES/django.po @@ -29915,8 +29915,8 @@ msgstr "Rechnung {invoice_number}" msgid "" "Hello,\n" "\n" -"you receive this message because an order for {event} was placed by " -"{order_email} and we have been asked to forward the invoice to you.\n" +"please find attached a new invoice for order {code} for {event}. " +"This order has been placed by {order_email}.\n" "\n" "Best regards, \n" "\n" @@ -29924,9 +29924,8 @@ msgid "" msgstr "" "Hallo,\n" "\n" -"Sie erhalten diese Nachricht weil eine Bestellung für {event} von " -"{order_email} aufgegeben wurde und wir darum gebeten wurden, Ihnen die " -"Rechnung weiterzuleiten.\n" +"anbei erhalten Sie eine neue Rechnung für Bestellung {code} für {event}. " +"Die Bestellung wurde von {order_email} aufgegeben.\n" "\n" "Viele Grüße, \n" "Das {event} Team" diff --git a/src/pretix/locale/de_Informal/LC_MESSAGES/django.po b/src/pretix/locale/de_Informal/LC_MESSAGES/django.po index 19e659ac21..b9c0a9adf7 100644 --- a/src/pretix/locale/de_Informal/LC_MESSAGES/django.po +++ b/src/pretix/locale/de_Informal/LC_MESSAGES/django.po @@ -29869,8 +29869,8 @@ msgstr "Rechnung {invoice_number}" msgid "" "Hello,\n" "\n" -"you receive this message because an order for {event} was placed by " -"{order_email} and we have been asked to forward the invoice to you.\n" +"please find attached a new invoice for order {code} for {event}. " +"This order has been placed by {order_email}.\n" "\n" "Best regards, \n" "\n" @@ -29878,9 +29878,8 @@ msgid "" msgstr "" "Hallo,\n" "\n" -"du erhältst diese Nachricht weil eine Bestellung für {event} von " -"{order_email} aufgegeben wurde und wir darum gebeten wurden, dir die " -"Rechnung weiterzuleiten.\n" +"anbei erhältst du eine neue Rechnung für Bestellung {code} für {event}. " +"Die Bestellung wurde von {order_email} aufgegeben.\n" "\n" "Viele Grüße, \n" "Das {event} Team" diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 7848ce58c0..441e3c070a 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -40,7 +40,6 @@ from django import forms from django.core.exceptions import ValidationError from django.http import HttpRequest from django.template.loader import get_template -from django.utils.functional import cached_property from django.utils.translation import gettext, gettext_lazy as _ from i18nfield.fields import I18nFormField, I18nTextarea from i18nfield.forms import I18nTextInput @@ -49,15 +48,9 @@ from localflavor.generic.forms import BICFormField, IBANFormField from localflavor.generic.validators import IBANValidator from text_unidecode import unidecode -from pretix.base.email import get_available_placeholders, get_email_context -from pretix.base.forms import PlaceholderValidator -from pretix.base.forms.widgets import format_placeholders_help_text -from pretix.base.i18n import language from pretix.base.models import InvoiceAddress, Order, OrderPayment, OrderRefund from pretix.base.payment import BasePaymentProvider -from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.templatetags.money import money_filter -from pretix.helpers.format import format_map from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat from pretix.presale.views.cart import cart_session @@ -208,48 +201,6 @@ class BankTransfer(BasePaymentProvider): @property def settings_form_fields(self): - placeholders = get_available_placeholders(self.event, ['event', 'order', 'invoice']) - phs_ht = format_placeholders_help_text(placeholders, self.event) - - phs = ['{%s}' % p for p in placeholders] - more_fields = OrderedDict([ - ('invoice_email', - forms.BooleanField( - label=_('Allow users to enter an additional email address that the invoice will be sent to.'), - help_text=_( - 'This requires that the invoice creation settings allow the invoice to be created right after ' - 'the payment method was chosen. Only the invoice will be sent to this email address, subsequent ' - 'invoice corrections will not be sent automatically. Only the invoice will be sent, no additional ' - 'information.' - ), - required=False, - )), - ('invoice_email_subject', - I18nFormField( - label=_('Invoice email subject'), - widget=I18nTextInput, - widget_kwargs={'attrs': { - 'data-display-dependency': '#id_payment_banktransfer_invoice_email', - 'data-required-if': '#id_payment_banktransfer_invoice_email', - }}, - validators=[PlaceholderValidator(phs)], - help_text=phs_ht, - required=False - )), - ('invoice_email_text', - I18nFormField( - label=_('Invoice email text'), - widget=I18nTextarea, - widget_kwargs={'attrs': { - 'rows': '8', - 'data-display-dependency': '#id_payment_banktransfer_invoice_email', - 'data-required-if': '#id_payment_banktransfer_invoice_email', - }}, - validators=[PlaceholderValidator(phs)], - help_text=phs_ht, - required=False - )), - ]) more_fields_first = OrderedDict([ ('_restricted_business', forms.BooleanField( @@ -263,8 +214,7 @@ class BankTransfer(BasePaymentProvider): d = OrderedDict( list(super().settings_form_fields.items()) + list(more_fields_first.items()) + - list(BankTransfer.form_fields().items()) + - list(more_fields.items()) + list(BankTransfer.form_fields().items()) ) d.move_to_end('invoice_immediately', last=False) d.move_to_end('bank_details', last=False) @@ -292,16 +242,6 @@ class BankTransfer(BasePaymentProvider): {'payment_banktransfer_bank_details': _('Please enter your bank account details.')}) return cleaned_data - @cached_property - def _invoice_email_asked(self): - return ( - self.settings.get('invoice_email', as_type=bool) and - (self.event.settings.invoice_generate == 'True' or ( - self.event.settings.invoice_generate == 'paid' and - self.settings.get('invoice_immediately', as_type=bool) - )) - ) - def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: def get_invoice_address(): if not hasattr(request, '_checkout_flow_invoice_address'): @@ -324,35 +264,10 @@ class BankTransfer(BasePaymentProvider): return super().is_allowed(request, total) - @property - def payment_form_fields(self) -> dict: - if self._invoice_email_asked: - return { - 'send_invoice': forms.BooleanField( - label=_('Please additionally send my invoice directly to our accounting department'), - required=False, - ), - 'send_invoice_to': forms.EmailField( - label=_('Invoice recipient email'), - required=False, - help_text=_('The invoice recipient will receive an email which includes the invoice and your email ' - 'address so they know who placed this order.'), - widget=forms.EmailInput( - attrs={ - 'data-display-dependency': '#id_payment_banktransfer-send_invoice', - } - ) - ), - } - else: - return {} - def payment_form_render(self, request, total=None, order=None) -> str: template = get_template('pretixplugins/banktransfer/checkout_payment_form.html') - form = self.payment_form(request) ctx = { 'request': request, - 'form': form, 'event': self.event, 'settings': self.settings, 'code': self._code(order) if order else None, @@ -361,97 +276,16 @@ class BankTransfer(BasePaymentProvider): return template.render(ctx) def checkout_prepare(self, request, total): - form = self.payment_form(request) - if form.is_valid(): - for k, v in form.cleaned_data.items(): - request.session['payment_%s_%s' % (self.identifier, k)] = v - return True - else: - return False - - def send_invoice_to_alternate_email(self, order, invoice, email): - """ - Sends an email to the alternate invoice address. - """ - with language(order.locale, self.event.settings.region): - context = get_email_context(event=self.event, - order=order, - invoice=invoice, - event_or_subevent=self.event, - invoice_address=getattr(order, 'invoice_address', None) or InvoiceAddress()) - template = self.settings.get('invoice_email_text', as_type=LazyI18nString) - subject = self.settings.get('invoice_email_subject', as_type=LazyI18nString) - - try: - email_content = render_mail(template, context) - subject = format_map(subject, context) - mail( - email, - subject, - template, - context=context, - event=self.event, - locale=order.locale, - order=order, - invoices=[invoice], - attach_tickets=False, - auto_email=True, - attach_ical=False, - plain_text_only=True, - no_order_links=True, - ) - except SendMailException: - raise - else: - order.log_action( - 'pretix.plugins.banktransfer.order.email.invoice', - data={ - 'subject': subject, - 'message': email_content, - 'position': None, - 'recipient': email, - 'invoices': [invoice.pk], - 'attach_tickets': False, - 'attach_ical': False, - } - ) - - def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: - send_invoice = ( - self._invoice_email_asked and - request and - request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice')) and - request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to')) - ) - if send_invoice: - recipient = request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to')) - payment.info_data = { - 'send_invoice_to': recipient, - } - payment.save(update_fields=['info']) - i = payment.order.invoices.filter(is_cancellation=False).last() - if i: - self.send_invoice_to_alternate_email(payment.order, i, recipient) - if request: - request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice'), None) - request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice_to'), None) + return True def payment_prepare(self, request: HttpRequest, payment: OrderPayment): - return self.checkout_prepare(request, payment.amount) + return True def payment_is_valid_session(self, request): return True def checkout_confirm_render(self, request, order=None): - template = get_template('pretixplugins/banktransfer/checkout_confirm.html') - ctx = { - 'request': request, - 'event': self.event, - 'settings': self.settings, - 'code': self._code(order) if order else None, - 'details': self.settings.get('bank_details', as_type=LazyI18nString), - } - return template.render(ctx) + return self.payment_form_render(request, order=order) def order_pending_mail_render(self, order, payment) -> str: t = gettext("Please transfer the full amount to the following bank account:") @@ -537,7 +371,6 @@ class BankTransfer(BasePaymentProvider): 'pending_description': self.settings.get('pending_description', as_type=LazyI18nString), 'details': self.settings.get('bank_details', as_type=LazyI18nString), 'has_invoices': payment.order.invoices.exists(), - 'invoice_email_enabled': self.settings.get('invoice_email', as_type=bool), } ctx['any_barcodes'] = ctx['swiss_qrbill'] or ctx['eu_barcodes'] return template.render(ctx, request=request) diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index 8bb3c647e2..af9fd730b6 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -22,8 +22,7 @@ from django.dispatch import receiver from django.template.loader import get_template from django.urls import resolve, reverse -from django.utils.translation import gettext_lazy as _, gettext_noop -from i18nfield.strings import LazyI18nString +from django.utils.translation import gettext_lazy as _ from pretix.base.signals import register_payment_providers from pretix.control.signals import html_head, nav_event, nav_organizer @@ -31,7 +30,6 @@ from pretix.control.signals import html_head, nav_event, nav_organizer from ...base.logentrytypes import ( ClearDataShredderMixin, OrderLogEntryType, log_entry_types, ) -from ...base.settings import settings_hierarkey from .payment import BankTransfer @@ -122,23 +120,6 @@ def html_head_presale(sender, request=None, **kwargs): @log_entry_types.new() class BanktransferOrderEmailInvoiceLogEntryType(OrderLogEntryType, ClearDataShredderMixin): + # For backwards-compatibility only action_type = 'pretix.plugins.banktransfer.order.email.invoice' plain = _('The invoice was sent to the designated email address.') - - -settings_hierarkey.add_default( - 'payment_banktransfer_invoice_email_subject', - default_type=LazyI18nString, - value=LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}")) -) -settings_hierarkey.add_default( - 'payment_banktransfer_invoice_email_text', - default_type=LazyI18nString, - value=LazyI18nString.from_gettext(gettext_noop("""Hello, - -you receive this message because an order for {event} was placed by {order_email} and we have been asked to forward the invoice to you. - -Best regards, - -Your {event} team""")) # noqa: W291 -) diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_confirm.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_confirm.html index f62dcaeb26..edc99b41d3 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_confirm.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_confirm.html @@ -38,10 +38,3 @@ reference code. {% endblocktrans %}

{% endif %} -{% if request.session.payment_banktransfer_send_invoice and request.session.payment_banktransfer_send_invoice_to %} -

- {% blocktrans trimmed with recipient=request.session.payment_banktransfer_send_invoice_to %} - We will send a copy of your invoice directly to {{ recipient }}. - {% endblocktrans %} -

-{% endif %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html index 7f74955da3..62a58dd469 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html @@ -1,6 +1,5 @@ {% load i18n %} {% load ibanformat %} -{% load bootstrap3 %} {% if details or code %}

{% blocktrans trimmed %} @@ -38,8 +37,3 @@ reference code. {% endblocktrans %}

{% endif %} -{% if form.fields %} -
- {% bootstrap_form form layout='inline' %} -
-{% endif %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html index 77d054e653..d1788f8545 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html @@ -133,42 +133,4 @@ SCT {% if swiss_qrbill %} -{% endif %} - -{% if invoice_email_enabled and has_invoices %} - {% if payment_info.send_invoice_to %} -

- {% blocktrans trimmed with recipient=payment_info.send_invoice_to %} - At your request, we sent the invoice directly to {{ recipient }}. - {% endblocktrans %} - - - {% trans "Send again or somewhere else" %} - -

- {% endif %} - - {% csrf_token %} -

- {% blocktrans trimmed %} - To send the invoice directly to your accounting department, please enter their email address: - {% endblocktrans %} -

-
-
- - -
-
- -
-
- -
-{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/pretix/plugins/banktransfer/urls.py b/src/pretix/plugins/banktransfer/urls.py index b1ad0ee019..968d0a9f1b 100644 --- a/src/pretix/plugins/banktransfer/urls.py +++ b/src/pretix/plugins/banktransfer/urls.py @@ -19,19 +19,13 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from django.urls import include, re_path +from django.urls import re_path from pretix.api.urls import orga_router from pretix.plugins.banktransfer.api import BankImportJobViewSet from . import views -event_patterns = [ - re_path(r'^banktransfer/', include([ - re_path(r'^(?P[^/][^w]+)/(?P[A-Za-z0-9]+)/mail-invoice/$', views.SendInvoiceMailView.as_view(), name='mail_invoice'), - ])), -] - urlpatterns = [ re_path(r'^control/organizer/(?P[^/]+)/banktransfer/import/', views.OrganizerImportView.as_view(), diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index c17beca849..e5adeefdb0 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -44,18 +44,14 @@ from typing import Set from django import forms from django.contrib import messages -from django.core.exceptions import ValidationError -from django.core.validators import validate_email from django.db import transaction from django.db.models import Count, Q, QuerySet -from django.http import FileResponse, Http404, JsonResponse +from django.http import FileResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext as _ -from django.views.decorators.clickjacking import xframe_options_exempt from django.views.generic import DetailView, FormView, ListView, View from django.views.generic.detail import SingleObjectMixin from localflavor.generic.forms import BICFormField, IBANFormField @@ -79,8 +75,6 @@ from pretix.plugins.banktransfer.refund_export import ( build_sepa_xml, get_refund_export_csv, ) from pretix.plugins.banktransfer.tasks import process_banktransfers -from pretix.presale.views import EventViewMixin -from pretix.presale.views.order import OrderDetailMixin logger = logging.getLogger('pretix.plugins.banktransfer') @@ -892,42 +886,3 @@ class OrganizerSepaXMLExportView(OrganizerPermissionRequiredMixin, OrganizerDeta organizer=self.request.organizer, pk=self.kwargs.get('id') ) - - -@method_decorator(xframe_options_exempt, 'dispatch') -class SendInvoiceMailView(EventViewMixin, OrderDetailMixin, View): - def post(self, request, *args, **kwargs): - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) - try: - validate_email(request.POST['email']) - except ValidationError: - messages.error(request, _('Please enter a valid email address.')) - return redirect(self.get_order_url()) - - last_payment = self.order.payments.last() - if (not last_payment - or last_payment.provider != BankTransfer.identifier - or last_payment.state != OrderPayment.PAYMENT_STATE_CREATED): - messages.error(request, _('No pending bank transfer payment found. Maybe the order has been paid already?')) - return redirect(self.get_order_url()) - if not last_payment.payment_provider.settings.get('invoice_email', as_type=bool): - messages.error(request, _('Sending invoices via email is disabled by the event organizer.')) - return redirect(self.get_order_url()) - - last_invoice = self.order.invoices.last() - if not last_invoice: - messages.error(request, _('No invoice found, please request an invoice first.')) - return redirect(self.get_order_url()) - - provider = last_payment.payment_provider - provider.send_invoice_to_alternate_email(self.order, last_invoice, request.POST['email']) - - last_payment.info_data = { - **last_payment.info_data, - 'send_invoice_to': request.POST['email'], - } - last_payment.save(update_fields=['info']) - - messages.success(request, _('Sending the latest invoice via email to {email}.').format(email=request.POST['email'])) - return redirect(self.get_order_url()) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index a637e7afad..f1c965124d 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1130,7 +1130,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): 'invoice' in self.request.GET or # Checking for self.invoice_address.pk is not enough as when an invoice_address has been added and later edited to be empty, it’s not None. # So check initial values as invoice_form can receive pre-filled values from invoice_address, widget-data or overwrites from plug-ins. - is_form_filled(self.invoice_form, ignore_keys=('is_business', 'country')) + is_form_filled(self.invoice_form, ignore_keys=('is_business', 'country', 'transmission_type')) ) if self.cart_customer: @@ -1144,6 +1144,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): "_state_for_address": a.state_for_address, "_name": a.name, "is_business": "business" if a.is_business else "individual", + **a.transmission_info, } if a.name_parts: name_parts = a.name_parts @@ -1165,7 +1166,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): for k in ( "company", "street", "zipcode", "city", "country", "state", - "state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary" + "state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary", + "transmission_type", ): v = getattr(a, k) or "" # always add all values of an address even when empty, diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index b347eaeca2..f0e2a60bf1 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -111,6 +111,10 @@
{% trans "Internal reference" %}
{{ addr.internal_reference }}
{% endif %} + {% for k, v in addr.describe_transmission %} +
{{ k }}
+
{{ v }}
+ {% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index 1a67a01c8f..b2b1497660 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html @@ -40,7 +40,9 @@ {% if addresses_data %} {{ addresses_data|json_script:"addresses_json" }} {% endif %} -
+
{% if addresses_data %}
@@ -78,7 +80,7 @@
-
+
{% if event.settings.attendee_data_explanation_text and pos.item.ask_attendee_data %} {{ event.settings.attendee_data_explanation_text|rich_text }} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index b2cd3a312e..5583b279fc 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -335,6 +335,10 @@
{% trans "Internal Reference" %}
{{ order.invoice_address.internal_reference }}
{% endif %} + {% for k, v in order.invoice_address.describe_transmission %} +
{{ k }}
+
{{ v }}
+ {% endfor %} {% endif %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/order_modify.html b/src/pretix/presale/templates/pretixpresale/event/order_modify.html index cb0887fcf3..02d08304cd 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_modify.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_modify.html @@ -35,14 +35,15 @@ -
+
{% if event.settings.invoice_address_explanation_text %}
{{ event.settings.invoice_address_explanation_text|rich_text }}
{% endif %} - {% bootstrap_form invoice_form layout="horizontal" %} + {% bootstrap_form invoice_form layout="checkout" %}
diff --git a/src/pretix/static/pretixbase/js/addressform.js b/src/pretix/static/pretixbase/js/addressform.js index 59645a9c0f..81c1f9af13 100644 --- a/src/pretix/static/pretixbase/js/addressform.js +++ b/src/pretix/static/pretixbase/js/addressform.js @@ -1,83 +1,127 @@ $(function () { "use strict"; - $("select[data-country-information-url]").each(function () { + $("[data-address-information-url]").each(function () { let xhr; - const dependency = $(this), - loader = $("").hide().prependTo(dependency.closest(".form-group").find("label")), - url = this.getAttribute('data-country-information-url'), - form = dependency.closest(".panel-body, form, .profile-scope"), - isRequired = dependency.closest(".form-group").is(".required"), - dependents = { - 'city': form.find("input[name$=city]"), - 'zipcode': form.find("input[name$=zipcode]"), - 'street': form.find("textarea[name$=street]"), - 'state': form.find("select[name$=state]"), - 'vat_id': form.find("input[name$=vat_id]"), - }, - update = function (ev) { - if (xhr) { - xhr.abort(); + const form = $(this); + const dependencies = $(this).find("[data-trigger-address-info]"); + const loader = $("").hide().prependTo(dependencies.closest(".form-group").find("label").first()) + const baseUrl = this.getAttribute('data-address-information-url') + const isAnyRequired = dependencies.toArray().some(function (e) { return $(e).closest(".form-group").is(".required") }); + + const dependents = { + 'city': form.find("input[name$=city]"), + 'zipcode': form.find("input[name$=zipcode]"), + 'street': form.find("textarea[name$=street]"), + 'state': form.find("select[name$=state]"), + 'vat_id': form.find("input[name$=vat_id]"), + }; + + form.find("select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]").each(function () { + dependents[$(this).attr("name").split("-").pop()] = $(this) + }) + + const update = function (ev) { + if (xhr) { + xhr.abort(); + } + + dependents.state.prop("data-selected-value", dependents.state.val()); + if (dependents.transmission_type) { + dependents.transmission_type.prop("data-selected-value", dependents.transmission_type.val()); + } + + for (var k in dependents) dependents[k].prop("disabled", true); + loader.show(); + var url = new URL(baseUrl, location.href); + // Address depends on all annotated fields + form.find("[data-trigger-address-info]").each(function () { + // Remove prefix of the form to get actual field name + if (($(this).attr("type") === "radio" || $(this).attr("type") === "checkbox") && !$(this).prop("checked")) { + return } - for (var k in dependents) dependents[k].prop("disabled", true); - loader.show(); - xhr = $.getJSON(url + '?country=' + dependency.val(), function (data) { - var selected_value = dependents.state.prop("data-selected-value"); - if (selected_value) dependents.state.prop("data-selected-value", ""); - dependents.state.find("option:not([value=''])").remove(); - if (data.data.length > 0) { - $.each(data.data, function (k, s) { - var o = $("