forked from CGM_Public/pretix_original
Pluggable invoice transmission methods (#5020)
* Flexible invoice transmission
* UI work
* Add peppol and output
* API support
* Profile integration
* Simplify form for individuals
* Remove sent_to_customer usage
* more steps
* Revert "Bank transfer: Allow to send the invoice direclty to the accounting department (#2975)"
This reverts commit cea6c340be.
* minor fixes
* Fixes after rebase
* update stati
* Backend view
* Transmit and show status
* status, retransmission
* API retransmission
* More fields
* API docs
* Plugin docs
* Update migration
* Add missing license headers
* Remove dead code, fix current tests
* Run isort
* Update regex
* Rebase migration
* Fix migration
* Add tests, fix bugs
* Rebase migration
* Apply suggestion from @luelista
Co-authored-by: luelista <weller@rami.io>
* Apply suggestion from @luelista
Co-authored-by: luelista <weller@rami.io>
* Apply suggestion from @luelista
Co-authored-by: luelista <weller@rami.io>
* Apply suggestion from @luelista
Co-authored-by: luelista <weller@rami.io>
* Apply suggestion from @luelista
Co-authored-by: luelista <weller@rami.io>
* Make migration reversible
* Add TransmissionType.enforce_transmission
* Fix registries API usage after rebase
* Remove code I forgot to delete
* Update transmission status display depending on type
* Add testmode_supported
* Update src/pretix/static/pretixbase/js/addressform.js
Co-authored-by: luelista <weller@rami.io>
* Update src/pretix/static/pretixbase/js/addressform.js
Co-authored-by: luelista <weller@rami.io>
* Update src/pretix/static/pretixbase/js/addressform.js
Co-authored-by: luelista <weller@rami.io>
* New mechanism for non-required invoice forms
* Update src/pretix/base/invoicing/transmission.py
Co-authored-by: luelista <weller@rami.io>
* Declare testmode_supported for email
* Make transmission_email_other an implementation detail
* Fix failing tests and add new ones
* Update src/pretix/base/services/invoices.py
Co-authored-by: luelista <weller@rami.io>
* Add emails to email history
* Fix comma error
* More generic default email text
* Cleanup
* Remove "email invoices" button and refine logic
* Rebase migration
* Fix edge case
---------
Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
@@ -19,45 +19,16 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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):
|
||||
|
||||
115
src/pretix/api/serializers/forms.py
Normal file
115
src/pretix/api/serializers/forms.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
src/pretix/base/invoicing/__init__.py
Normal file
21
src/pretix/base/invoicing/__init__.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
173
src/pretix/base/invoicing/email.py
Normal file
173
src/pretix/base/invoicing/email.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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': [],
|
||||
}
|
||||
)
|
||||
75
src/pretix/base/invoicing/national.py
Normal file
75
src/pretix/base/invoicing/national.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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"}
|
||||
1107
src/pretix/base/invoicing/pdf.py
Normal file
1107
src/pretix/base/invoicing/pdf.py
Normal file
File diff suppressed because it is too large
Load Diff
167
src/pretix/base/invoicing/peppol.py
Normal file
167
src/pretix/base/invoicing/peppol.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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"}
|
||||
246
src/pretix/base/invoicing/transmission.py
Normal file
246
src/pretix/base/invoicing/transmission.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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_<identifier>_``.
|
||||
"""
|
||||
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)),
|
||||
)
|
||||
75
src/pretix/base/migrations/0288_invoice_transmission.py
Normal file
75
src/pretix/base/migrations/0288_invoice_transmission.py
Normal file
@@ -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'",
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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="█")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load getitem %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Invoice settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
@@ -59,6 +60,99 @@
|
||||
{% bootstrap_field form.invoice_renderer_highlight_order_code layout="control" %}
|
||||
{% bootstrap_field form.invoice_eu_currencies layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice transmission" %}</legend>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Transmission method" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for t in transmission_types %}
|
||||
{% if transmission_providers|getitem:t.identifier %}
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="colgroup" class="text-muted">
|
||||
{{ t.verbose_name }}
|
||||
</th>
|
||||
<th>
|
||||
{% if ready|getitem:t.identifier %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check fa-fw"></span>
|
||||
{% trans "Available" %}
|
||||
{% if t.exclusive %}
|
||||
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
|
||||
{% trans "(exclusive)" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-ban fa-fw"></span>
|
||||
{% trans "Unavailable" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody>
|
||||
{% for p, is_ready, settings_url in transmission_providers|getitem:t.identifier %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ p.verbose_name }}
|
||||
</td>
|
||||
<td>
|
||||
{% if is_ready %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check fa-fw"></span>
|
||||
{% trans "Available" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-ban fa-fw"></span>
|
||||
{% trans "Not configured" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if settings_url %}
|
||||
<a href="{{ settings_url }}" class="btn btn-default">
|
||||
<span class="fa fa-cog" aria-hidden="true"></span>
|
||||
{% trans "Settings" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
<a href="{{ plugin_settings_url }}" class="btn btn-default">
|
||||
<i class="fa fa-plus"></i> {% trans "Enable additional invoice transmission plugins" %}
|
||||
</a>
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
|
||||
|
||||
{% blocktrans asvar title_order_custom_mail %}Invoice{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_invoice" title=title_order_custom_mail items="mail_subject_order_invoice,mail_text_order_invoice" %}
|
||||
|
||||
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
{% endif %}</strong>
|
||||
</h4>
|
||||
</summary>
|
||||
<div id="invoice">
|
||||
<div id="invoice"
|
||||
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ request.event.organizer.slug|urlencode }}&event={{ request.event.slug|urlencode }}">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_form invoice_form layout="horizontal" %}
|
||||
</div>
|
||||
|
||||
@@ -271,24 +271,70 @@
|
||||
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}" target="_blank">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a>
|
||||
({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if i.sent_to_customer.year == 1970 %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
|
||||
({{ i.date|date:"SHORT_DATE_FORMAT" }}, {{ i.transmission_type_instance.verbose_name }})
|
||||
{% if i.transmission_status == "unknown" %}
|
||||
{# Legacy invoice, before the introduction of transmission status #}
|
||||
{% if i.transmission_date.year == 1970 %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% elif i.transmission_date %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif i.transmission_status == "pending" %}
|
||||
{% if i.transmission_type_instance.enforce_transmission %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is scheduled to be transmitted" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-clock-o text-success fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-envelope fa-background text-muted" data-toggle="tooltip" title="{% trans "Invoice is not yet transmitted" %}"></span>
|
||||
{% endif %}
|
||||
{% elif i.transmission_status == "inflight" %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is currently in transmission" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
|
||||
<span class="fa fa-send text-success fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% elif i.sent_to_customer %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
|
||||
{% elif i.transmission_status == "testmode_ignored" %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice not transmitted in test mode" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-ban text-warning fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% elif i.transmission_status == "failed" %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice transmission failed" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-exclamation-circle text-danger fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% elif i.transmission_status == "completed" %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice has been transmitted" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
|
||||
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if i.transmission_status != "inflight" %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.retransmitinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs" data-toggle="tooltip"
|
||||
title="{{ i.transmission_type_instance.verbose_name }}">
|
||||
{% if i.transmission_status == "pending" %}
|
||||
{% trans "Transmit" %}
|
||||
{% else %}
|
||||
{% trans "Retransmit" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not i.canceled %}
|
||||
{% if request.event.settings.invoice_regenerate_allowed %}
|
||||
{% if i.regenerate_allowed %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.regeninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
@@ -320,12 +366,6 @@
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if invoices_send_link %}
|
||||
<br/>
|
||||
<a class="btn btn-default btn-xs" href="{{ invoices_send_link }}">
|
||||
{% trans "Email invoices" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
|
||||
<br/>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
@@ -989,8 +1029,14 @@
|
||||
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
|
||||
<dd>{{ order.invoice_address.custom_field }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Internal reference" %}</dt>
|
||||
<dd>{{ order.invoice_address.internal_reference }}</dd>
|
||||
{% if order.invoice_address.internal_reference %}
|
||||
<dt>{% trans "Internal reference" %}</dt>
|
||||
<dd>{{ order.invoice_address.internal_reference }}</dd>
|
||||
{% endif %}
|
||||
{% for k, v in order.invoice_address.describe_transmission %}
|
||||
<dt>{{ k }}</dt>
|
||||
<dd>{{ v }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -383,6 +383,8 @@ urlpatterns = [
|
||||
name='event.order.geninvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/regenerate$', orders.OrderInvoiceRegenerate.as_view(),
|
||||
name='event.order.regeninvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/retransmit$', orders.OrderInvoiceRetransmit.as_view(),
|
||||
name='event.order.retransmitinvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/reissue$', orders.OrderInvoiceReissue.as_view(),
|
||||
name='event.order.reissueinvoice'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<position>\d+)/(?P<output>[^/]+)/$',
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -38,10 +38,3 @@
|
||||
reference code.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% if request.session.payment_banktransfer_send_invoice and request.session.payment_banktransfer_send_invoice_to %}
|
||||
<p>
|
||||
{% blocktrans trimmed with recipient=request.session.payment_banktransfer_send_invoice_to %}
|
||||
We will send a copy of your invoice directly to {{ recipient }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load ibanformat %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% if details or code %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
@@ -38,8 +37,3 @@
|
||||
reference code.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% if form.fields %}
|
||||
<div class="col-md-12">
|
||||
{% bootstrap_form form layout='inline' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -133,42 +133,4 @@ SCT
|
||||
</div>
|
||||
{% if swiss_qrbill %}
|
||||
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
|
||||
{% endif %}
|
||||
|
||||
{% if invoice_email_enabled and has_invoices %}
|
||||
{% if payment_info.send_invoice_to %}
|
||||
<p>
|
||||
{% blocktrans trimmed with recipient=payment_info.send_invoice_to %}
|
||||
At your request, we sent the invoice directly to {{ recipient }}.
|
||||
{% endblocktrans %}
|
||||
<a data-toggle="collapse" data-target="#payment_banktransfer_send_invoice_to" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
{% trans "Send again or somewhere else" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form id="payment_banktransfer_send_invoice_to" method="post"
|
||||
action="{% eventurl event "plugins:banktransfer:mail_invoice" order=order.code secret=order.secret %}"
|
||||
{% if payment_info.send_invoice_to %}class="collapse"{% endif %}>
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
To send the invoice directly to your accounting department, please enter their email address:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-9 col-xs-12">
|
||||
<label for="mail_invoice_email" class="sr-only">{% trans "Invoice recipient email" %}:</label>
|
||||
<input type="email" name="email" id="mail_invoice_email" class="form-control" value="" required
|
||||
placeholder="{% trans "Email address" %}" />
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-12">
|
||||
<button class="btn btn-default btn-block">
|
||||
<span class="fa fa-envelope-o" aria-hidden="true"></span>
|
||||
{% trans "Send invoice via email" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -19,19 +19,13 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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<order>[^/][^w]+)/(?P<secret>[A-Za-z0-9]+)/mail-invoice/$', views.SendInvoiceMailView.as_view(), name='mail_invoice'),
|
||||
])),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^control/organizer/(?P<organizer>[^/]+)/banktransfer/import/',
|
||||
views.OrganizerImportView.as_view(),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
<dt>{% trans "Internal reference" %}</dt>
|
||||
<dd>{{ addr.internal_reference }}</dd>
|
||||
{% endif %}
|
||||
{% for k, v in addr.describe_transmission %}
|
||||
<dt>{{ k }}</dt>
|
||||
<dd>{{ v }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
{% if addresses_data %}
|
||||
{{ addresses_data|json_script:"addresses_json" }}
|
||||
{% endif %}
|
||||
<div id="invoice" class="profile-scope" data-profiles-id="addresses_json">
|
||||
<div id="invoice" class="profile-scope"
|
||||
data-profiles-id="addresses_json"
|
||||
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ event.organizer.slug|urlencode }}&event={{ event.slug|urlencode }}">
|
||||
<div class="panel-body">
|
||||
{% if addresses_data %}
|
||||
<div class="form-group profile-select-container js-do-not-copy-answers">
|
||||
@@ -78,7 +80,7 @@
|
||||
</h3>
|
||||
</summary>
|
||||
<div>
|
||||
<div class="panel-body questions-form">
|
||||
<div class="panel-body questions-form" data-address-information-url="{% url "js_helpers.address_form" %}">
|
||||
{% if event.settings.attendee_data_explanation_text and pos.item.ask_attendee_data %}
|
||||
{{ event.settings.attendee_data_explanation_text|rich_text }}
|
||||
{% endif %}
|
||||
|
||||
@@ -335,6 +335,10 @@
|
||||
<dt>{% trans "Internal Reference" %}</dt>
|
||||
<dd>{{ order.invoice_address.internal_reference }}</dd>
|
||||
{% endif %}
|
||||
{% for k, v in order.invoice_address.describe_transmission %}
|
||||
<dt>{{ k }}</dt>
|
||||
<dd>{{ v }}</dd>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -35,14 +35,15 @@
|
||||
</strong>
|
||||
</h4>
|
||||
</summary>
|
||||
<div id="invoice" class="panel-collapse">
|
||||
<div id="invoice" class="panel-collapse"
|
||||
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ event.organizer.slug|urlencode }}&event={{ event.slug|urlencode }}">
|
||||
<div class="panel-body">
|
||||
{% if event.settings.invoice_address_explanation_text %}
|
||||
<div>
|
||||
{{ event.settings.invoice_address_explanation_text|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_form invoice_form layout="horizontal" %}
|
||||
{% bootstrap_form invoice_form layout="checkout" %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -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 = $("<span class='fa fa-cog fa-spin'></span>").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 = $("<span class='fa fa-cog fa-spin'></span>").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 = $("<option>").attr("value", s.code).text(s.name);
|
||||
if (selected_value == s.code) o.prop("selected", true);
|
||||
dependents.state.append(o);
|
||||
});
|
||||
}
|
||||
for(var k in dependents) {
|
||||
const options = data[k],
|
||||
dependent = dependents[k];
|
||||
let visible = 'visible' in options ? options.visible : true;
|
||||
|
||||
if (dependent.is("[data-display-dependency]")) {
|
||||
const dependency = $(dependent.attr("data-display-dependency"));
|
||||
visible = visible && (
|
||||
(dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val()
|
||||
);
|
||||
}
|
||||
|
||||
if ('label' in options) {
|
||||
dependent.closest(".form-group").find(".control-label").text(options.label);
|
||||
}
|
||||
|
||||
const required = 'required' in options && options.required && isRequired && visible;
|
||||
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
|
||||
dependent.prop("required", required);
|
||||
const label = dependent.closest(".form-group").find("label");
|
||||
const labelRequired = label.find(".label-required");
|
||||
if (!required) {
|
||||
labelRequired.remove();
|
||||
} else if (!labelRequired.length) {
|
||||
label.append('<i class="label-required">' + gettext('required') + '</i>')
|
||||
}
|
||||
}
|
||||
for (var k in dependents) dependents[k].prop("disabled", false);
|
||||
}).always(function() {
|
||||
loader.hide();
|
||||
}).fail(function(){
|
||||
// In case of errors, show everything and require nothing, we can still handle errors in backend
|
||||
for(var k in dependents) {
|
||||
const dependent = dependents[k],
|
||||
visible = true,
|
||||
required = false;
|
||||
|
||||
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
|
||||
dependent.prop("required", required);
|
||||
dependent.closest(".form-group").find("label .label-required").remove();
|
||||
}
|
||||
url.searchParams.append($(this).attr("name").split("-").pop(), $(this).val());
|
||||
})
|
||||
if (dependents.transmission_type) {
|
||||
url.searchParams.append("transmission_type_required", !dependents.transmission_type.find("option[value='-']").length);
|
||||
}
|
||||
xhr = $.getJSON(url, function (data) {
|
||||
var selected_state = dependents.state.prop("data-selected-value");
|
||||
if (selected_state) dependents.state.prop("data-selected-value", "");
|
||||
dependents.state.find("option:not([value=''])").remove();
|
||||
$.each(data.data, function (k, s) {
|
||||
var o = $("<option>").attr("value", s.code).text(s.name);
|
||||
if (selected_state === s.code) o.prop("selected", true);
|
||||
dependents.state.append(o);
|
||||
});
|
||||
};
|
||||
dependents.state.prop("data-selected-value", dependents.state.val());
|
||||
|
||||
if (dependents.transmission_type) {
|
||||
var selected_transmission_type = dependents.transmission_type.prop("data-selected-value");
|
||||
if (selected_transmission_type) dependents.transmission_type.prop("data-selected-value", "");
|
||||
dependents.transmission_type.find("option:not([value='']):not([value='-'])").remove();
|
||||
|
||||
if (!data.transmission_type.visible) {
|
||||
selected_transmission_type = "email";
|
||||
}
|
||||
|
||||
$.each(data.transmission_types, function (k, s) {
|
||||
var o = $("<option>").attr("value", s.code).text(s.name);
|
||||
if (selected_transmission_type === s.code) {
|
||||
o.prop("selected", true);
|
||||
}
|
||||
dependents.transmission_type.append(o);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
for (var k in dependents) {
|
||||
const options = data[k],
|
||||
dependent = dependents[k];
|
||||
let visible = 'visible' in options ? options.visible : true;
|
||||
|
||||
if (dependent.is("[data-display-dependency]")) {
|
||||
const dependency = $(dependent.attr("data-display-dependency"));
|
||||
visible = visible && (
|
||||
(dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val()
|
||||
);
|
||||
}
|
||||
|
||||
if ('label' in options) {
|
||||
dependent.closest(".form-group").find(".control-label").text(options.label);
|
||||
}
|
||||
|
||||
const required = 'required' in options && visible && (
|
||||
(options.required === 'if_any' && isAnyRequired) ||
|
||||
(options.required === true)
|
||||
);
|
||||
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
|
||||
dependent.prop("required", required);
|
||||
|
||||
const label = dependent.closest(".form-group").find("label");
|
||||
const labelRequired = label.find(".label-required");
|
||||
if (!required) {
|
||||
labelRequired.remove();
|
||||
} else if (!labelRequired.length) {
|
||||
label.append('<i class="label-required">' + gettext('required') + '</i>')
|
||||
}
|
||||
}
|
||||
for (var k in dependents) dependents[k].prop("disabled", false);
|
||||
}).always(function() {
|
||||
loader.hide();
|
||||
}).fail(function(){
|
||||
// In case of errors, show everything and require nothing, we can still handle errors in backend
|
||||
for(var k in dependents) {
|
||||
const dependent = dependents[k],
|
||||
visible = true,
|
||||
required = false;
|
||||
|
||||
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
|
||||
dependent.prop("required", required);
|
||||
}
|
||||
});
|
||||
};
|
||||
update();
|
||||
dependency.on("change", update);
|
||||
dependencies.on("change", update);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -58,7 +58,11 @@ base_patterns = [
|
||||
name='metrics'),
|
||||
re_path(r'^csp_report/$', csp.csp_report, name='csp.report'),
|
||||
re_path(r'^agpl_source$', source.get_source, name='source'),
|
||||
re_path(r'^js_helpers/states/$', js_helpers.states, name='js_helpers.states'),
|
||||
|
||||
re_path(r'^js_helpers/address_form/$', js_helpers.address_form, name='js_helpers.address_form'),
|
||||
# deprecated name, keep for compatibility with old plugins
|
||||
re_path(r'^js_helpers/states/$', js_helpers.address_form, name='js_helpers.states'),
|
||||
|
||||
re_path(r'^api/v1/', include(('pretix.api.urls', 'pretixapi'), namespace='api-v1')),
|
||||
re_path(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
|
||||
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',
|
||||
|
||||
Reference in New Issue
Block a user