diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index c2f4c5ba5..7964a9bb3 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -340,10 +340,17 @@ class TaxRule(LoggedModel): rules = self._custom_rules if invoice_address: for r in rules: - if r['country'] == 'EU' and not is_eu_country(invoice_address.country): - continue - if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country): - continue + if r['country'] == 'ZZ': # Rule: Any country + pass + elif r['country'] == 'EU': # Rule: Any EU country + if not is_eu_country(invoice_address.country): + continue + elif '-' in r['country']: # Rule: Specific country and state + if r['country'] != str(invoice_address.country) + '-' + str(invoice_address.state): + continue + else: # Rule: Specific country + if r['country'] != str(invoice_address.country): + continue if r['address_type'] == 'individual' and invoice_address.is_business: continue if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business: diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 1b82f3d63..f1da406bb 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -38,6 +38,7 @@ from decimal import Decimal from urllib.parse import urlencode, urlparse from zoneinfo import ZoneInfo +import pycountry from django import forms from django.conf import settings from django.core.exceptions import NON_FIELD_ERRORS, ValidationError @@ -65,7 +66,8 @@ from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.settings import ( - PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, + COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES, + PERSON_NAME_TITLE_GROUPS, validate_event_settings, ) from pretix.base.validators import multimail_validate from pretix.control.forms import ( @@ -1428,9 +1430,20 @@ class CountriesAndEU(CachedCountries): cache_subkey = 'with_any_or_eu' +class CountriesAndEUAndStates(CountriesAndEU): + def __iter__(self): + for country_code, country_name in super().__iter__(): + yield country_code, country_name + if country_code in COUNTRIES_WITH_STATE_IN_ADDRESS: + types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[country_code] + yield from sorted(((state.code, country_name + " - " + state.name) + for state in pycountry.subdivisions.get(country_code=country_code) + if state.type in types), key=lambda s: s[1]) + + class TaxRuleLineForm(I18nForm): country = LazyTypedChoiceField( - choices=CountriesAndEU(), + choices=CountriesAndEUAndStates(), required=False ) address_type = forms.ChoiceField( diff --git a/src/pretix/static/schema/tax-rules-custom.schema.json b/src/pretix/static/schema/tax-rules-custom.schema.json index 3eaa30d3d..5792c05be 100644 --- a/src/pretix/static/schema/tax-rules-custom.schema.json +++ b/src/pretix/static/schema/tax-rules-custom.schema.json @@ -8,8 +8,8 @@ "description": "List of rules, executed in order until one matches", "properties": { "country": { - "description": "Country code to match. ZZ = any country, EU = any EU country.", - "enum": ["AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "EU", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", "ZZ"] + "description": "Country code to match. ZZ = any country, EU = any EU country. For selected countries, a state can be matched (e.g. US-NY for New York).", + "enum": ["ZZ", "EU", "AF", "EG", "AX", "AL", "DZ", "AS", "VI", "AD", "AO", "AI", "AQ", "AG", "GQ", "AR", "AM", "AW", "AZ", "ET", "AU", "AU-ACT", "AU-NSW", "AU-NT", "AU-QLD", "AU-SA", "AU-TAS", "AU-VIC", "AU-WA", "BS", "BH", "BD", "BB", "BE", "BZ", "BJ", "BM", "BT", "BO", "BQ", "BA", "BW", "BV", "BR", "BR-AC", "BR-AL", "BR-AP", "BR-AM", "BR-BA", "BR-CE", "BR-ES", "BR-GO", "BR-MA", "BR-MT", "BR-MS", "BR-MG", "BR-PR", "BR-PB", "BR-PA", "BR-PE", "BR-PI", "BR-RN", "BR-RS", "BR-RJ", "BR-RO", "BR-RR", "BR-SC", "BR-SE", "BR-SP", "BR-TO", "VG", "IO", "BN", "BG", "BF", "BI", "CL", "CN", "MP", "CK", "CR", "CI", "CW", "DK", "DE", "DM", "DO", "DJ", "EC", "SV", "ER", "EE", "FK", "FO", "FJ", "FI", "FR", "GF", "PF", "TF", "GA", "GM", "GE", "GH", "GI", "GD", "GR", "GL", "GP", "GU", "GT", "GG", "GN", "GW", "GY", "HT", "HM", "HN", "HK", "IN", "ID", "IQ", "IR", "IE", "IS", "IM", "IL", "IT", "JM", "JP", "YE", "JE", "JO", "KY", "KH", "CM", "CA", "CA-AB", "CA-BC", "CA-MB", "CA-NB", "CA-NL", "CA-NT", "CA-NS", "CA-NU", "CA-ON", "CA-PE", "CA-QC", "CA-SK", "CA-YT", "CV", "KZ", "QA", "KE", "KG", "KI", "CC", "CO", "KM", "CG", "CD", "HR", "CU", "KW", "LA", "LS", "LV", "LB", "LR", "LY", "LI", "LT", "LU", "MO", "MG", "MW", "MY", "MY-01", "MY-02", "MY-03", "MY-04", "MY-05", "MY-06", "MY-08", "MY-09", "MY-07", "MY-12", "MY-13", "MY-10", "MY-11", "MV", "ML", "MT", "MA", "MH", "MQ", "MR", "MU", "YT", "MK", "MX", "MX-AGU", "MX-BCN", "MX-BCS", "MX-CAM", "MX-CHP", "MX-CHH", "MX-CMX", "MX-COA", "MX-COL", "MX-DUR", "MX-GUA", "MX-GRO", "MX-HID", "MX-JAL", "MX-MIC", "MX-MOR", "MX-MEX", "MX-NAY", "MX-NLE", "MX-OAX", "MX-PUE", "MX-QUE", "MX-ROO", "MX-SLP", "MX-SIN", "MX-SON", "MX-TAB", "MX-TAM", "MX-TLA", "MX-VER", "MX-YUC", "MX-ZAC", "FM", "MD", "MC", "MN", "ME", "MS", "MZ", "MM", "NA", "NR", "NP", "NC", "NZ", "NI", "NL", "NE", "NG", "NU", "KP", "NF", "NO", "OM", "AT", "TL", "PK", "PS", "PW", "PA", "PG", "PY", "PE", "PH", "PN", "PL", "PT", "PR", "RE", "RW", "RO", "RU", "BL", "PM", "SB", "ZM", "WS", "SM", "ST", "SA", "SE", "CH", "SN", "RS", "SC", "SL", "ZW", "SG", "SX", "SK", "SI", "SO", "ES", "SJ", "LK", "SH", "KN", "LC", "MF", "VC", "ZA", "SD", "GS", "KR", "SS", "SR", "SZ", "SY", "TJ", "TW", "TZ", "TH", "TG", "TK", "TO", "TT", "TD", "CZ", "TN", "TR", "TM", "TC", "TV", "UG", "UA", "HU", "UY", "UM", "UZ", "VU", "VA", "VE", "AE", "US", "US-AL", "US-AK", "US-AS", "US-AZ", "US-AR", "US-CA", "US-CO", "US-CT", "US-DE", "US-DC", "US-FL", "US-GA", "US-GU", "US-HI", "US-ID", "US-IL", "US-IN", "US-IA", "US-KS", "US-KY", "US-LA", "US-ME", "US-MD", "US-MA", "US-MI", "US-MN", "US-MS", "US-MO", "US-MT", "US-NE", "US-NV", "US-NH", "US-NJ", "US-NM", "US-NY", "US-NC", "US-ND", "US-MP", "US-OH", "US-OK", "US-OR", "US-PA", "US-PR", "US-RI", "US-SC", "US-SD", "US-TN", "US-TX", "US-UM", "US-UT", "US-VT", "US-VI", "US-VA", "US-WA", "US-WV", "US-WI", "US-WY", "GB", "VN", "WF", "CX", "BY", "EH", "CF", "CY"] }, "address_type": { "description": "Type of customer, emtpy = any.", diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index ab90939ce..b643fd4ba 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -477,6 +477,81 @@ def test_custom_rules_specific_country(event): ) +@pytest.mark.django_db +def test_custom_rules_specific_state(event): + tr = TaxRule( + event=event, + rate=Decimal('10.00'), price_includes_tax=False, + custom_rules=json.dumps([ + {'country': 'US-NY', 'address_type': '', 'action': 'vat', 'rate': '20.00'}, + {'country': 'US-DE', 'address_type': '', 'action': 'no'}, + {'country': 'US', 'address_type': '', 'action': 'vat', 'rate': '30.00'}, + ]) + ) + ia = InvoiceAddress( + is_business=True, + country=Country('DE') + ) + assert not tr.is_reverse_charge(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax(Decimal('100.00'), invoice_address=ia) == TaxedPrice( + gross=Decimal('110.00'), + net=Decimal('100.00'), + tax=Decimal('10.00'), + rate=Decimal('10.00'), + name='', + ) + + ia = InvoiceAddress( + is_business=True, + country=Country('US'), + state='NC' + ) + assert not tr.is_reverse_charge(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('30.00') + assert tr.tax(Decimal('100.00'), invoice_address=ia) == TaxedPrice( + gross=Decimal('130.00'), + net=Decimal('100.00'), + tax=Decimal('30.00'), + rate=Decimal('30.00'), + name='', + ) + + ia = InvoiceAddress( + is_business=True, + country=Country('US'), + state='NY' + ) + assert not tr.is_reverse_charge(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('20.00') + assert tr.tax(Decimal('100.00'), invoice_address=ia) == TaxedPrice( + gross=Decimal('120.00'), + net=Decimal('100.00'), + tax=Decimal('20.00'), + rate=Decimal('20.00'), + name='', + ) + + ia = InvoiceAddress( + is_business=True, + country=Country('US'), + state='DE' + ) + assert not tr.is_reverse_charge(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') + assert tr.tax(Decimal('100.00'), invoice_address=ia) == TaxedPrice( + gross=Decimal('100.00'), + net=Decimal('100.00'), + tax=Decimal('0.00'), + rate=Decimal('0.00'), + name='', + ) + + @pytest.mark.django_db def test_custom_rules_individual(event): tr = TaxRule(