Invoice issuer address: Add state field (#5603)

* Invoice issuer address: Add state field

* Update src/pretix/base/settings.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/base/models/invoices.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2025-11-14 09:56:46 +01:00
committed by GitHub
parent 5583298322
commit eb740204d4
13 changed files with 126 additions and 9 deletions

View File

@@ -22,6 +22,7 @@ invoice_from_name string Sender address:
invoice_from string Sender address: Address lines invoice_from string Sender address: Address lines
invoice_from_zipcode string Sender address: ZIP code invoice_from_zipcode string Sender address: ZIP code
invoice_from_city string Sender address: City invoice_from_city string Sender address: City
invoice_from_state string Sender address: State (only used in some countries)
invoice_from_country string Sender address: Country code invoice_from_country string Sender address: Country code
invoice_from_tax_id string Sender address: Local Tax ID invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID invoice_from_vat_id string Sender address: EU VAT ID
@@ -233,6 +234,7 @@ List of all invoices
"invoice_from": "Demo street 12", "invoice_from": "Demo street 12",
"invoice_from_zipcode":"", "invoice_from_zipcode":"",
"invoice_from_city":"Demo town", "invoice_from_city":"Demo town",
"invoice_from_state":"CA",
"invoice_from_country":"US", "invoice_from_country":"US",
"invoice_from_tax_id":"", "invoice_from_tax_id":"",
"invoice_from_vat_id":"", "invoice_from_vat_id":"",
@@ -381,6 +383,7 @@ Fetching individual invoices
"invoice_from": "Demo street 12", "invoice_from": "Demo street 12",
"invoice_from_zipcode":"", "invoice_from_zipcode":"",
"invoice_from_city":"Demo town", "invoice_from_city":"Demo town",
"invoice_from_state":"CA",
"invoice_from_country":"US", "invoice_from_country":"US",
"invoice_from_tax_id":"", "invoice_from_tax_id":"",
"invoice_from_vat_id":"", "invoice_from_vat_id":"",

View File

@@ -820,6 +820,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_from', 'invoice_address_from',
'invoice_address_from_zipcode', 'invoice_address_from_zipcode',
'invoice_address_from_city', 'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country', 'invoice_address_from_country',
'invoice_address_from_tax_id', 'invoice_address_from_tax_id',
'invoice_address_from_vat_id', 'invoice_address_from_vat_id',
@@ -952,6 +953,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_from', 'invoice_address_from',
'invoice_address_from_zipcode', 'invoice_address_from_zipcode',
'invoice_address_from_city', 'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country', 'invoice_address_from_country',
'invoice_address_from_tax_id', 'invoice_address_from_tax_id',
'invoice_address_from_vat_id', 'invoice_address_from_vat_id',

View File

@@ -1831,7 +1831,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Invoice model = Invoice
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode', fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id', 'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale', 'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',

View File

@@ -209,6 +209,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice sender:') + ' ' + _('Address'), _('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'), _('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'), _('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + pgettext('address', 'State'),
_('Invoice sender:') + ' ' + _('Country'), _('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'), _('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'), _('Invoice sender:') + ' ' + _('VAT ID'),
@@ -291,6 +292,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
i.invoice_from, i.invoice_from,
i.invoice_from_zipcode, i.invoice_from_zipcode,
i.invoice_from_city, i.invoice_from_city,
i.invoice_from_state,
i.invoice_from_country, i.invoice_from_country,
i.invoice_from_tax_id, i.invoice_from_tax_id,
i.invoice_from_vat_id, i.invoice_from_vat_id,

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.24 on 2025-11-10 16:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0295_user_is_verified"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="invoice_from_state",
field=models.CharField(max_length=190, null=True),
),
]

View File

@@ -142,6 +142,7 @@ class Invoice(models.Model):
invoice_from_name = models.CharField(max_length=190, null=True) invoice_from_name = models.CharField(max_length=190, null=True)
invoice_from_zipcode = models.CharField(max_length=190, null=True) invoice_from_zipcode = models.CharField(max_length=190, null=True)
invoice_from_city = models.CharField(max_length=190, null=True) invoice_from_city = models.CharField(max_length=190, null=True)
invoice_from_state = models.CharField(max_length=190, null=True)
invoice_from_country = FastCountryField(null=True) invoice_from_country = FastCountryField(null=True)
invoice_from_tax_id = models.CharField(max_length=190, null=True) invoice_from_tax_id = models.CharField(max_length=190, null=True)
invoice_from_vat_id = models.CharField(max_length=190, null=True) invoice_from_vat_id = models.CharField(max_length=190, null=True)
@@ -218,10 +219,23 @@ class Invoice(models.Model):
taxidrow = "ABN: %s" % self.invoice_from_tax_id taxidrow = "ABN: %s" % self.invoice_from_tax_id
else: else:
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
state_name = ""
if self.invoice_from_state:
state_name = self.invoice_from_state
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
try:
state_name = pycountry.subdivisions.get(
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
).name
except:
pass
parts = [ parts = [
self.invoice_from_name, self.invoice_from_name,
self.invoice_from, self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""), ((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
self.invoice_from_country.name if self.invoice_from_country else "", self.invoice_from_country.name if self.invoice_from_country else "",
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "", pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
taxidrow, taxidrow,
@@ -230,10 +244,22 @@ class Invoice(models.Model):
@property @property
def address_invoice_from(self): def address_invoice_from(self):
state_name = ""
if self.invoice_from_state:
state_name = self.invoice_from_state
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
try:
state_name = pycountry.subdivisions.get(
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
).name
except:
pass
parts = [ parts = [
self.invoice_from_name, self.invoice_from_name,
self.invoice_from, self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""), " ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s)
self.invoice_from_country.name if self.invoice_from_country else "", self.invoice_from_country.name if self.invoice_from_country else "",
] ]
return '\n'.join([p.strip() for p in parts if p and p.strip()]) return '\n'.join([p.strip() for p in parts if p and p.strip()])

View File

@@ -93,6 +93,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name') invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city') invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country') invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id') invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id') invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -459,6 +460,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name') cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city') cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
cancellation.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country') cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id') cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id') cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -562,6 +564,7 @@ def build_preview_invoice_pdf(event):
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name') invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city') invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country') invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id') invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id') invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')

View File

@@ -40,6 +40,7 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
import pycountry
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -1229,13 +1230,32 @@ DEFAULTS = {
label=_("City"), label=_("City"),
) )
}, },
'invoice_address_from_state': {
'default': '',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': {
'choices': [('', '')],
},
'form_kwargs': {
"label": pgettext_lazy('address', 'State'),
'choices': [('', '')],
},
},
'invoice_address_from_country': { 'invoice_address_from_country': {
'default': '', 'default': '',
'type': str, 'type': str,
'form_class': forms.ChoiceField, 'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField, 'serializer_class': serializers.ChoiceField,
'serializer_kwargs': lambda: dict(**country_choice_kwargs()), 'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
'form_kwargs': lambda: dict(label=_('Country'), **country_choice_kwargs()), 'form_kwargs': lambda: dict(
label=_('Country'),
widget=forms.Select(attrs={
'data-trigger-address-info': 'on',
}),
**country_choice_kwargs()
),
}, },
'invoice_address_from_tax_id': { 'invoice_address_from_tax_id': {
'default': '', 'default': '',
@@ -3971,6 +3991,16 @@ def validate_event_settings(event, settings_dict):
raise ValidationError({ raise ValidationError({
'invoice_address_company_required': _('You have to require invoice addresses to require for company names.') 'invoice_address_company_required': _('You have to require invoice addresses to require for company names.')
}) })
if settings_dict.get('invoice_address_from_state') and settings_dict.get('invoice_address_from_country'):
cc = str(settings_dict.get('invoice_address_from_country'))
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'invoice_address_from_state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + settings_dict.get('invoice_address_from_state')):
raise ValidationError(
{'invoice_address_from_state': ['"{}" is not a known subdivision of the country "{}".'.format(settings_dict.get('invoice_address_from_state'), cc)]}
)
payment_term_last = settings_dict.get('payment_term_last') payment_term_last = settings_dict.get('payment_term_last')
if payment_term_last and event.presale_end: if payment_term_last and event.presale_end:

View File

@@ -67,8 +67,9 @@ from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, DEFAULTS,
PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
validate_event_settings,
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
@@ -945,6 +946,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_address_from', 'invoice_address_from',
'invoice_address_from_zipcode', 'invoice_address_from_zipcode',
'invoice_address_from_city', 'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country', 'invoice_address_from_country',
'invoice_address_from_tax_id', 'invoice_address_from_tax_id',
'invoice_address_from_vat_id', 'invoice_address_from_vat_id',
@@ -1017,6 +1019,26 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
(a, a) for a in get_fonts(event, pdf_support_required=True).keys() (a, a) for a in get_fonts(event, pdf_support_required=True).keys()
] ]
if 'invoice_address_from_country' in self.data:
cc = str(self.data['invoice_address_from_country'])
elif 'invoice_address_from_country' in self.initial:
cc = str(self.initial['invoice_address_from_country'])
else:
cc = self.obj.settings.invoice_address_from_country
c = [('', '---')]
state_label = pgettext_lazy('address', 'State')
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
if cc in COUNTRY_STATE_LABEL:
state_label = COUNTRY_STATE_LABEL[cc]
elif 'invoice_address_from_state' in self.data:
self.data = self.data.copy()
del self.data['invoice_address_from_state']
self.fields['invoice_address_from_state'].choices = c
self.fields['invoice_address_from_state'].label = state_label
def contains_web_channel_validate(val): def contains_web_channel_validate(val):
if "web" not in val: if "web" not in val:

View File

@@ -49,13 +49,14 @@
{% bootstrap_field form.invoice_address_custom_field_helptext layout="control" %} {% bootstrap_field form.invoice_address_custom_field_helptext layout="control" %}
{% bootstrap_field form.invoice_address_explanation_text layout="control" %} {% bootstrap_field form.invoice_address_explanation_text layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset data-address-information-url="{% url "js_helpers.address_form" %}">
<legend>{% trans "Issuer details" %}</legend> <legend>{% trans "Issuer details" %}</legend>
{% bootstrap_field form.invoice_address_from_name layout="control" %} {% bootstrap_field form.invoice_address_from_name layout="control" %}
{% bootstrap_field form.invoice_address_from layout="control" %} {% bootstrap_field form.invoice_address_from layout="control" %}
{% bootstrap_field form.invoice_address_from_zipcode layout="control" %} {% bootstrap_field form.invoice_address_from_zipcode layout="control" %}
{% bootstrap_field form.invoice_address_from_city layout="control" %} {% bootstrap_field form.invoice_address_from_city layout="control" %}
{% bootstrap_field form.invoice_address_from_country layout="control" %} {% bootstrap_field form.invoice_address_from_country layout="control" %}
{% bootstrap_field form.invoice_address_from_state layout="control" %}
{% bootstrap_field form.invoice_address_from_tax_id layout="control" %} {% bootstrap_field form.invoice_address_from_tax_id layout="control" %}
{% bootstrap_field form.invoice_address_from_vat_id layout="control" %} {% bootstrap_field form.invoice_address_from_vat_id layout="control" %}
</fieldset> </fieldset>

View File

@@ -5,6 +5,14 @@ $(function () {
// to prevent fetching the same thing many times. // to prevent fetching the same thing many times.
var responseCache = {}; var responseCache = {};
const cleanName = (name) => {
// Remove form prefix
name = name.split("-").pop();
// Remove settings prefix
name = name.replace(/^invoice_address_from_/, "");
return name
}
$("[data-address-information-url]").each(function () { $("[data-address-information-url]").each(function () {
let xhr; let xhr;
const form = $(this); const form = $(this);
@@ -22,7 +30,7 @@ $(function () {
}; };
form.find("select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]").each(function () { form.find("select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]").each(function () {
dependents[$(this).attr("name").split("-").pop()] = $(this) dependents[cleanName($(this).attr("name"))] = $(this)
}) })
if (!Object.values(dependents).some((el) => el.length)) { if (!Object.values(dependents).some((el) => el.length)) {
@@ -109,7 +117,7 @@ $(function () {
if (($(this).attr("type") === "radio" || $(this).attr("type") === "checkbox") && !$(this).prop("checked")) { if (($(this).attr("type") === "radio" || $(this).attr("type") === "checkbox") && !$(this).prop("checked")) {
return return
} }
url.searchParams.append($(this).attr("name").split("-").pop(), $(this).val()); url.searchParams.append(cleanName($(this).attr("name")), $(this).val());
}) })
if (dependents.transmission_type) { if (dependents.transmission_type) {
url.searchParams.append("transmission_type_required", !dependents.transmission_type.find("option[value='-']").length); url.searchParams.append("transmission_type_required", !dependents.transmission_type.find("option[value='-']").length);

View File

@@ -197,6 +197,7 @@ TEST_INVOICE_RES = {
"invoice_from": "", "invoice_from": "",
"invoice_from_zipcode": "", "invoice_from_zipcode": "",
"invoice_from_city": "", "invoice_from_city": "",
"invoice_from_state": "",
"invoice_from_country": None, "invoice_from_country": None,
"invoice_from_tax_id": "", "invoice_from_tax_id": "",
"invoice_from_vat_id": "", "invoice_from_vat_id": "",

View File

@@ -578,6 +578,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
"invoice_from": "", "invoice_from": "",
"invoice_from_zipcode": "", "invoice_from_zipcode": "",
"invoice_from_city": "", "invoice_from_city": "",
"invoice_from_state": "",
"invoice_from_country": None, "invoice_from_country": None,
"invoice_from_tax_id": "", "invoice_from_tax_id": "",
"invoice_from_vat_id": "", "invoice_from_vat_id": "",