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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user