diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index de9245941f..3a97ca412a 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -22,6 +22,7 @@ invoice_from_name string Sender address: invoice_from string Sender address: Address lines invoice_from_zipcode string Sender address: ZIP code 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_tax_id string Sender address: Local Tax 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_zipcode":"", "invoice_from_city":"Demo town", + "invoice_from_state":"CA", "invoice_from_country":"US", "invoice_from_tax_id":"", "invoice_from_vat_id":"", @@ -381,6 +383,7 @@ Fetching individual invoices "invoice_from": "Demo street 12", "invoice_from_zipcode":"", "invoice_from_city":"Demo town", + "invoice_from_state":"CA", "invoice_from_country":"US", "invoice_from_tax_id":"", "invoice_from_vat_id":"", diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index b233077e85..1f1c40cf28 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -820,6 +820,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_address_from', 'invoice_address_from_zipcode', 'invoice_address_from_city', + 'invoice_address_from_state', 'invoice_address_from_country', 'invoice_address_from_tax_id', 'invoice_address_from_vat_id', @@ -952,6 +953,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'invoice_address_from', 'invoice_address_from_zipcode', 'invoice_address_from_city', + 'invoice_address_from_state', 'invoice_address_from_country', 'invoice_address_from_tax_id', 'invoice_address_from_vat_id', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 808768fc2b..1459e1c26d 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1831,7 +1831,7 @@ class InvoiceSerializer(I18nAwareModelSerializer): class Meta: model = Invoice 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_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', diff --git a/src/pretix/base/exporters/invoices.py b/src/pretix/base/exporters/invoices.py index 8aa3dba22c..630a15b777 100644 --- a/src/pretix/base/exporters/invoices.py +++ b/src/pretix/base/exporters/invoices.py @@ -209,6 +209,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter): _('Invoice sender:') + ' ' + _('Address'), _('Invoice sender:') + ' ' + _('ZIP code'), _('Invoice sender:') + ' ' + _('City'), + _('Invoice sender:') + ' ' + pgettext('address', 'State'), _('Invoice sender:') + ' ' + _('Country'), _('Invoice sender:') + ' ' + _('Tax ID'), _('Invoice sender:') + ' ' + _('VAT ID'), @@ -291,6 +292,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter): i.invoice_from, i.invoice_from_zipcode, i.invoice_from_city, + i.invoice_from_state, i.invoice_from_country, i.invoice_from_tax_id, i.invoice_from_vat_id, diff --git a/src/pretix/base/migrations/0296_invoice_invoice_from_state.py b/src/pretix/base/migrations/0296_invoice_invoice_from_state.py new file mode 100644 index 0000000000..9b7ae9e77b --- /dev/null +++ b/src/pretix/base/migrations/0296_invoice_invoice_from_state.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 602e062d8d..af427c2854 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -142,6 +142,7 @@ class Invoice(models.Model): invoice_from_name = 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_state = models.CharField(max_length=190, null=True) invoice_from_country = FastCountryField(null=True) invoice_from_tax_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 else: 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 = [ self.invoice_from_name, 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 "", pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "", taxidrow, @@ -230,10 +244,22 @@ class Invoice(models.Model): @property 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 = [ self.invoice_from_name, 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 "", ] return '\n'.join([p.strip() for p in parts if p and p.strip()]) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 29b424a03c..45d65c4574 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -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_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') 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_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') @@ -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_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') 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_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') @@ -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_zipcode = invoice.event.settings.get('invoice_address_from_zipcode') 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_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') diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 60e510b286..e248fa8d89 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -40,6 +40,7 @@ from datetime import datetime from decimal import Decimal from typing import Any +import pycountry from django import forms from django.conf import settings from django.core.exceptions import ValidationError @@ -1229,13 +1230,32 @@ DEFAULTS = { 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': { 'default': '', 'type': str, 'form_class': forms.ChoiceField, 'serializer_class': serializers.ChoiceField, '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': { 'default': '', @@ -3971,6 +3991,16 @@ def validate_event_settings(event, settings_dict): raise ValidationError({ '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') if payment_term_last and event.presale_end: diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index f73d4bd4b1..5df8f714fe 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -67,8 +67,9 @@ from pretix.base.models.tax import TAX_CODE_LISTS from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.settings import ( - COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, - PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings, + COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, DEFAULTS, + PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, + validate_event_settings, ) from pretix.base.validators import multimail_validate from pretix.control.forms import ( @@ -945,6 +946,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_address_from', 'invoice_address_from_zipcode', 'invoice_address_from_city', + 'invoice_address_from_state', 'invoice_address_from_country', 'invoice_address_from_tax_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() ] + 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): if "web" not in val: diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index 52d9a7e9bb..0b9751dfd0 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -49,13 +49,14 @@ {% bootstrap_field form.invoice_address_custom_field_helptext layout="control" %} {% bootstrap_field form.invoice_address_explanation_text layout="control" %} -