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

@@ -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'