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

@@ -1199,6 +1199,20 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
required=False,
widget=I18nMarkdownTextarea,
)
mail_subject_order_invoice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_text_order_invoice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
@@ -1350,6 +1364,8 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_text_order_payment_failed': ['event', 'order'],
'mail_subject_order_payment_failed': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_order_invoice': ['event', 'order', 'invoice'],
'mail_subject_order_invoice': ['event', 'order', 'invoice'],
'mail_text_download_reminder': ['event', 'order'],
'mail_subject_download_reminder': ['event', 'order'],
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],

View File

@@ -524,6 +524,11 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.invoice.sent': _('The invoice {full_invoice_no} has been sent.'),
'pretix.event.order.invoice.sending_failed': _('The transmission of invoice {full_invoice_no} has failed.'),
'pretix.event.order.invoice.testmode_ignored': _('Invoice {full_invoice_no} has not been transmitted because '
'no transmission provider supports test mode invoices.'),
'pretix.event.order.invoice.retransmitted': _('The invoice {full_invoice_no} has been scheduled for retransmission.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
@@ -536,6 +541,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.email.error': _('Sending of an email has failed.'),
'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they '
'would have been too large to be likely to arrive.'),
'pretix.event.order.email.invoice': _('An invoice email has been sent.'),
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'),
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load getitem %}
{% block inside %}
<h1>{% trans "Invoice settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
@@ -59,6 +60,99 @@
{% bootstrap_field form.invoice_renderer_highlight_order_code layout="control" %}
{% bootstrap_field form.invoice_eu_currencies layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Invoice transmission" %}</legend>
<p>
{% blocktrans trimmed %}
pretix can transmit invoices using different transmission methods. Different transmission methods
might be required depending on country and industry. By default, sending invoices as PDF files
via email is always available. Other types of transmission can be added by plugins.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Whether a transmission method listed here is actually selectable for customers may depend on
the country of the customer or whether the customer is entering a business address.
{% endblocktrans %}
</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>{% trans "Transmission method" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
{% for t in transmission_types %}
{% if transmission_providers|getitem:t.identifier %}
<tbody>
<tr>
<th scope="colgroup" class="text-muted">
{{ t.verbose_name }}
</th>
<th>
{% if ready|getitem:t.identifier %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
{% if t.exclusive %}
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
{% trans "(exclusive)" %}
</span>
{% endif %}
</span>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Unavailable" %}
</span>
{% endif %}
</th>
<th></th>
</tr>
</tbody>
<tbody>
{% for p, is_ready, settings_url in transmission_providers|getitem:t.identifier %}
<tr>
<td>
{{ p.verbose_name }}
</td>
<td>
{% if is_ready %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
</span>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Not configured" %}
</span>
{% endif %}
</td>
<td class="text-right">
{% if settings_url %}
<a href="{{ settings_url }}" class="btn btn-default">
<span class="fa fa-cog" aria-hidden="true"></span>
{% trans "Settings" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
<p>
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
<a href="{{ plugin_settings_url }}" class="btn btn-default">
<i class="fa fa-plus"></i> {% trans "Enable additional invoice transmission plugins" %}
</a>
</p>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">

View File

@@ -117,6 +117,9 @@
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
{% blocktrans asvar title_order_custom_mail %}Invoice{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_invoice" title=title_order_custom_mail items="mail_subject_order_invoice,mail_text_order_invoice" %}
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}

View File

@@ -27,7 +27,8 @@
{% endif %}</strong>
</h4>
</summary>
<div id="invoice">
<div id="invoice"
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ request.event.organizer.slug|urlencode }}&event={{ request.event.slug|urlencode }}">
<div class="panel-body">
{% bootstrap_form invoice_form layout="horizontal" %}
</div>

View File

@@ -271,24 +271,70 @@
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}" target="_blank">
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
{{ i.number }}</a>
({{ i.date|date:"SHORT_DATE_FORMAT" }})
{% if i.sent_to_customer.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
({{ i.date|date:"SHORT_DATE_FORMAT" }}, {{ i.transmission_type_instance.verbose_name }})
{% if i.transmission_status == "unknown" %}
{# Legacy invoice, before the introduction of transmission status #}
{% if i.transmission_date.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_date %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% elif i.transmission_status == "pending" %}
{% if i.transmission_type_instance.enforce_transmission %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is scheduled to be transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-clock-o text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa fa-envelope fa-background text-muted" data-toggle="tooltip" title="{% trans "Invoice is not yet transmitted" %}"></span>
{% endif %}
{% elif i.transmission_status == "inflight" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is currently in transmission" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
<span class="fa fa-send text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.sent_to_customer %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
{% elif i.transmission_status == "testmode_ignored" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice not transmitted in test mode" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-ban text-warning fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "failed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice transmission failed" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-exclamation-circle text-danger fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "completed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice has been transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% if i.transmission_status != "inflight" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.retransmitinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
<button class="btn btn-default btn-xs" data-toggle="tooltip"
title="{{ i.transmission_type_instance.verbose_name }}">
{% if i.transmission_status == "pending" %}
{% trans "Transmit" %}
{% else %}
{% trans "Retransmit" %}
{% endif %}
</button>
</form>
{% endif %}
{% if not i.canceled %}
{% if request.event.settings.invoice_regenerate_allowed %}
{% if i.regenerate_allowed %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.regeninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
@@ -320,12 +366,6 @@
<br/>
{% endif %}
{% endfor %}
{% if invoices_send_link %}
<br/>
<a class="btn btn-default btn-xs" href="{{ invoices_send_link }}">
{% trans "Email invoices" %}
</a>
{% endif %}
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/>
<form class="form-inline helper-display-inline" method="post"
@@ -989,8 +1029,14 @@
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
<dd>{{ order.invoice_address.custom_field }}</dd>
{% endif %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if order.invoice_address.internal_reference %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% endif %}
{% for k, v in order.invoice_address.describe_transmission %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
</dl>
</div>
</div>

View File

@@ -383,6 +383,8 @@ urlpatterns = [
name='event.order.geninvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/regenerate$', orders.OrderInvoiceRegenerate.as_view(),
name='event.order.regeninvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/retransmit$', orders.OrderInvoiceRetransmit.as_view(),
name='event.order.retransmitinvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/reissue$', orders.OrderInvoiceReissue.as_view(),
name='event.order.reissueinvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<position>\d+)/(?P<output>[^/]+)/$',

View File

@@ -37,7 +37,7 @@ import json
import logging
import operator
import re
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from decimal import Decimal
from io import BytesIO
from itertools import groupby
@@ -76,6 +76,9 @@ from i18nfield.utils import I18nJSONEncoder
from pretix.base.email import get_available_placeholders
from pretix.base.forms import PlaceholderValidator
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers,
)
from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher
from pretix.base.models.event import EventMetaValue
from pretix.base.services import tickets
@@ -651,6 +654,22 @@ class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
template_name = 'pretixcontrol/event/invoicing.html'
permission = 'can_change_event_settings'
def get_context_data(self, **kwargs):
types = get_transmission_types()
providers = defaultdict(list)
ready = defaultdict(lambda: False)
for p, __ in transmission_providers.filter(active_in=self.request.event):
is_ready_result = p.is_ready(self.request.event)
providers[p.type].append((p, is_ready_result, p.settings_url(self.request.event)))
ready[p.type] = ready[p.type] or is_ready_result
for k, v in providers.items():
v.sort(key=lambda p: (-p[0].priority, p[0].identifier))
return super().get_context_data(
transmission_providers=providers,
transmission_types=types,
ready=ready,
)
def get_success_url(self) -> str:
if 'preview' in self.request.POST:
return reverse('control:event.settings.invoice.preview', kwargs={

View File

@@ -66,7 +66,7 @@ from django.utils.html import conditional_escape, escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _, ngettext
from django.utils.translation import gettext, gettext_lazy as _
from django.views.generic import (
DetailView, FormView, ListView, TemplateView, View,
)
@@ -93,7 +93,7 @@ from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export, scheduled_event_export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
invoice_qualified, regenerate_invoice,
invoice_qualified, regenerate_invoice, transmit_invoice,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import (
@@ -551,27 +551,6 @@ class OrderDetail(OrderView):
ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum
unsent_invoices = [ii.pk for ii in ctx['invoices'] if not ii.sent_to_customer]
if unsent_invoices:
with language(self.order.locale):
ctx['invoices_send_link'] = reverse('control:event.order.sendmail', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'code': self.order.code
}) + '?' + urlencode({
'subject': ngettext('Your invoice', 'Your invoices', len(unsent_invoices)),
'message': ngettext(
'Hello,\n\nplease find your invoice attached to this email.\n\n'
'Your {event} team',
'Hello,\n\nplease find your invoices attached to this email.\n\n'
'Your {event} team',
len(unsent_invoices)
).format(
event="{event}",
),
'attach_invoices': unsent_invoices
}, doseq=True)
return ctx
@cached_property
@@ -1681,6 +1660,8 @@ class OrderInvoiceRegenerate(OrderView):
else:
if not inv.event.settings.invoice_regenerate_allowed:
messages.error(self.request, _('Invoices may not be changed after they are created.'))
elif not inv.regenerate_allowed:
messages.error(self.request, _('Invoices may not be changed after they are transmitted.'))
if inv.canceled:
messages.error(self.request, _('The invoice has already been canceled.'))
elif inv.sent_to_organizer:
@@ -1701,6 +1682,37 @@ class OrderInvoiceRegenerate(OrderView):
return HttpResponseNotAllowed(['POST'])
class OrderInvoiceRetransmit(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
with transaction.atomic(durable=True):
try:
invoice = self.order.invoices.select_for_update(of=OF_SELF).get(pk=kwargs.get("id"))
except Invoice.DoesNotExist:
messages.error(self.request, _('Unknown invoice.'))
return redirect(self.get_order_url())
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
messages.error(self.request, _('The invoice is currently being transmitted. You can start a new attempt after '
'the current one has been completed.'))
return redirect(self.get_order_url())
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
messages.success(self.request, _('The invoice has been scheduled for retransmission.'))
self.order.log_action('pretix.event.order.invoice.retransmitted', user=self.request.user, data={
'invoice': invoice.pk,
'full_invoice_no': invoice.full_invoice_no,
})
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA
return HttpResponseNotAllowed(['POST'])
class OrderInvoiceReissue(OrderView):
permission = 'can_change_orders'