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

@@ -67,7 +67,7 @@ def event(organizer, meta_prop):
e = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc),
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf',
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf,tests.testdummy',
is_public=True
)
e.meta_values.create(property=meta_prop, value="Conference")

View File

@@ -28,7 +28,7 @@ import pytest
from django_countries.fields import Country
from django_scopes import scopes_disabled
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models import Invoice, InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice,
@@ -201,6 +201,7 @@ TEST_INVOICE_RES = {
"invoice_from_tax_id": "",
"invoice_from_vat_id": "",
"invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123",
"invoice_to_is_business": False,
"invoice_to_company": "Sample company",
"invoice_to_name": "",
"invoice_to_street": "",
@@ -210,6 +211,7 @@ TEST_INVOICE_RES = {
"invoice_to_country": "NZ",
"invoice_to_vat_id": "DE123",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": None,
"date": "2017-12-10",
"refers": None,
@@ -260,7 +262,11 @@ TEST_INVOICE_RES = {
"tax_name": "",
"tax_rate": "19.00"
}
]
],
"transmission_type": "email",
"transmission_provider": None,
"transmission_status": "pending",
"transmission_date": None
}
@@ -366,6 +372,26 @@ def test_invoice_detail(token_client, organizer, event, item, invoice):
assert res == resp.data
@pytest.mark.django_db
def test_invoice_retransmit(token_client, organizer, event, invoice):
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_INFLIGHT
invoice.save()
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/retransmit/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 409
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.save()
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/retransmit/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 204
invoice.refresh_from_db()
assert invoice.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
@pytest.mark.django_db
def test_invoice_regenerate(token_client, organizer, event, invoice):
organizer.settings.invoice_regenerate_allowed = True

View File

@@ -247,6 +247,147 @@ def test_order_update_state_validation(token_client, organizer, event, order):
assert order.invoice_address.country == "AU"
@pytest.mark.django_db
def test_order_update_transmission_validation(token_client, organizer, event, order):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": False,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"state": "",
"zipcode": "",
"city": "Paris",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "invalid",
"transmission_info": {},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {"transmission_type": ["Unknown transmission type."]}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {
"transmission_type": ["The selected transmission type is not available for this country or address type."]
}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "IT",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {"transmission_info": {
"transmission_it_sdi_pec": ["Enter a valid email address.", "Enter a valid email address."],
"transmission_it_sdi_recipient_code": ["This field is required."]
}}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "Via Da Vinci 1",
"zipcode": "12345",
"city": "Test",
"country": "IT",
"state": "MI",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar@pec.it",
"transmission_it_sdi_recipient_code": "1234567",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {
"invoice_address": {"vat_id": ["This field is required for the selected type of invoice transmission."]}
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "Via Una 1",
"zipcode": "12345",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "peppol",
"transmission_info": {
"transmission_peppol_participant_id": "9930:DE811569869",
"ignored": "parameter",
},
}
}
)
assert resp.status_code == 200
order.invoice_address.refresh_from_db()
assert order.invoice_address.transmission_type == "peppol"
assert order.invoice_address.transmission_info == {
"transmission_peppol_participant_id": "9930:DE811569869",
}
@pytest.mark.django_db
def test_order_update_allowed_fields(token_client, organizer, event, order):
event.settings.locales = ['de', 'en']
@@ -442,6 +583,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
"invoice_from_vat_id": "",
"invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123",
"invoice_to_company": "Sample company",
"invoice_to_is_business": False,
"invoice_to_name": "",
"invoice_to_street": "",
"invoice_to_zipcode": "",
@@ -450,6 +592,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
"invoice_to_country": "NZ",
"invoice_to_vat_id": "DE123",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": None,
'date': now().astimezone(event.timezone).date().isoformat(),
'refers': None,
@@ -500,7 +643,11 @@ def test_order_create_invoice(token_client, organizer, event, order):
'foreign_currency_display': None,
'foreign_currency_rate': None,
'foreign_currency_rate_date': None,
'internal_reference': ''
'internal_reference': '',
'transmission_date': None,
'transmission_provider': None,
'transmission_status': 'pending',
'transmission_type': 'email',
}
resp = token_client.post(

View File

@@ -436,7 +436,9 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'vat_id': '',
'vat_id_validated': False,
'internal_reference': '',
'custom_field': None
'custom_field': None,
'transmission_type': 'email',
'transmission_info': None,
},
'positions': [
{
@@ -590,6 +592,39 @@ def test_order_create_invoice_address_optional(token_client, organizer, event, i
o.invoice_address
@pytest.mark.django_db
def test_order_create_invoice_address_transmission_type_validation(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['invoice_address'] = {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar@pec.it",
"transmission_it_sdi_recipient_code": "1234567",
},
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {
"transmission_type": ["The selected transmission type is not available for this country or address type."]
}}
@pytest.mark.django_db
def test_order_create_sales_channel_optional(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)

View File

@@ -325,7 +325,9 @@ TEST_ORDER_RES = {
"internal_reference": "",
"custom_field": "Custom info",
"vat_id": "DE123",
"vat_id_validated": True
"vat_id_validated": True,
"transmission_type": "email",
"transmission_info": None,
},
"require_approval": False,
"valid_if_pending": False,

View File

@@ -75,6 +75,7 @@ event_permission_sub_urls = [
('get', 'can_view_orders', 'invoices/1/', 404),
('post', 'can_change_orders', 'invoices/1/regenerate/', 404),
('post', 'can_change_orders', 'invoices/1/reissue/', 404),
('post', 'can_change_orders', 'invoices/1/retransmit/', 404),
('get', 'can_view_orders', 'waitinglistentries/', 200),
('get', 'can_view_orders', 'waitinglistentries/1/', 404),
('post', 'can_change_orders', 'waitinglistentries/', 400),