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:
Raphael Michel
2025-08-19 17:59:45 +02:00
committed by GitHub
parent 37910f6037
commit 05c74b7ad6
65 changed files with 4514 additions and 1825 deletions

View 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/>.
#

View 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': [],
}
)

View 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"}

File diff suppressed because it is too large Load Diff

View 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"}

View 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)),
)