diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst
index 0067013bdc..dafae3fc3d 100644
--- a/doc/api/resources/taxrules.rst
+++ b/doc/api/resources/taxrules.rst
@@ -20,11 +20,16 @@ internal_name string An optional nam
rate decimal (string) Tax rate in percent
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price
-eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
+eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied. Will
+ be ignored if custom rules are set.
home_country string Merchant country (required for reverse charge), can be
``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
rules keep the gross price constant (default is ``false``)
+custom_rules object Dynamic rules specification. Each list element
+ corresponds to one rule that will be processed in order.
+ The current version of the schema in use can be found
+ `here`_.
===================================== ========================== =======================================================
@@ -32,6 +37,10 @@ keep_gross_if_rate_changes boolean If ``true``, ch
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
+.. versionchanged:: 2023.6
+
+ The ``custom_rules`` attribute has been added.
+
Endpoints
---------
@@ -68,6 +77,7 @@ Endpoints
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
+ "custom_rules": null,
"home_country": "DE"
}
]
@@ -108,6 +118,7 @@ Endpoints
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
+ "custom_rules": null,
"home_country": "DE"
}
@@ -156,6 +167,7 @@ Endpoints
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
+ "custom_rules": null,
"home_country": "DE"
}
@@ -203,6 +215,7 @@ Endpoints
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
+ "custom_rules": null,
"home_country": "DE"
}
@@ -242,3 +255,5 @@ Endpoints
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use.
+
+.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json
diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py
index 7a3fa6cb7b..0aa212c996 100644
--- a/src/pretix/api/serializers/__init__.py
+++ b/src/pretix/api/serializers/__init__.py
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import json
+
from rest_framework import serializers
@@ -46,3 +48,16 @@ class AsymmetricField(serializers.Field):
def run_validation(self, data=serializers.empty):
return self.write.run_validation(data)
+
+
+class CompatibleJSONField(serializers.JSONField):
+ def to_internal_value(self, data):
+ try:
+ return json.dumps(data)
+ except (TypeError, ValueError):
+ self.fail('invalid')
+
+ def to_representation(self, value):
+ if value:
+ return json.loads(value)
+ return value
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 9b0e6444cb..a12f6e0859 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -46,6 +46,7 @@ from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
+from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
@@ -53,6 +54,7 @@ from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
ItemMetaProperty, SubEventItem, SubEventItemVariation,
)
+from pretix.base.models.tax import CustomRulesValidator
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
@@ -650,9 +652,16 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
+ custom_rules = CompatibleJSONField(
+ validators=[CustomRulesValidator()],
+ required=False,
+ allow_null=True,
+ )
+
class Meta:
model = TaxRule
- fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
+ fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name',
+ 'keep_gross_if_rate_changes', 'custom_rules')
class EventSettingsSerializer(SettingsSerializer):
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index bf6b7deed4..b167fa5ecb 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-import json
import logging
import os
from collections import Counter, defaultdict
@@ -39,6 +38,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
+from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
@@ -896,19 +896,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
return data
-class CompatibleJSONField(serializers.JSONField):
- def to_internal_value(self, data):
- try:
- return json.dumps(data)
- except (TypeError, ValueError):
- self.fail('invalid')
-
- def to_representation(self, value):
- if value:
- return json.loads(value)
- return value
-
-
class WrappedList:
def __init__(self, data):
self._data = data
diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py
index 8971af632f..c2f4c5ba54 100644
--- a/src/pretix/base/models/tax.py
+++ b/src/pretix/base/models/tax.py
@@ -22,9 +22,12 @@
import json
from decimal import Decimal
+import jsonschema
+from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.utils.deconstruct import deconstructible
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.fields import I18nCharField
@@ -135,6 +138,25 @@ def cc_to_vat_prefix(country_code):
return country_code
+@deconstructible
+class CustomRulesValidator:
+ def __call__(self, value):
+ if not isinstance(value, dict):
+ try:
+ val = json.loads(value)
+ except ValueError:
+ raise ValidationError(_('Your layout file is not a valid JSON file.'))
+ else:
+ val = value
+ with open(finders.find('schema/tax-rules-custom.schema.json'), 'r') as f:
+ schema = json.loads(f.read())
+ try:
+ jsonschema.validate(val, schema)
+ except jsonschema.ValidationError as e:
+ e = str(e).replace('%', '%%')
+ raise ValidationError(_('Your set of rules is not valid. Error message: {}').format(e))
+
+
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField(
diff --git a/src/pretix/static/schema/tax-rules-custom.schema.json b/src/pretix/static/schema/tax-rules-custom.schema.json
new file mode 100644
index 0000000000..3eaa30d3d5
--- /dev/null
+++ b/src/pretix/static/schema/tax-rules-custom.schema.json
@@ -0,0 +1,49 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Tax rules",
+ "description": "Dynamic taxation rules",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "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"]
+ },
+ "address_type": {
+ "description": "Type of customer, emtpy = any.",
+ "enum": ["", "individual", "business", "business_vat_id"]
+ },
+ "action": {
+ "description": "Action to take.",
+ "enum": ["vat", "reverse", "no", "block", "require_approval"]
+ },
+ "rate": {
+ "description": "Tax rate in case of action=vat or action=require_approval (or null for default)",
+ "type": ["string", "null"],
+ "pattern": "^[0-9]+(\\.[0-9]+)?$"
+ },
+ "invoice_text": {
+ "description": "Text on invoice (localized)",
+ "type": ["object", "null"],
+ "patternProperties": {
+ "[a-zA-Z-]+": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "ORDER": {
+ "description": "Internal, for backwards-compatibility, will be ignored.",
+ "type": "number"
+ },
+ "DELETE": {
+ "description": "Internal, for backwards-compatibility, will be ignored.",
+ "type": "boolean"
+ }
+ },
+ "required": ["country", "address_type", "action"],
+ "additionalProperties": false
+ }
+}
diff --git a/src/tests/api/test_taxrules.py b/src/tests/api/test_taxrules.py
index 0216cc299d..807db12b98 100644
--- a/src/tests/api/test_taxrules.py
+++ b/src/tests/api/test_taxrules.py
@@ -33,7 +33,8 @@ TEST_TAXRULE_RES = {
'rate': '19.00',
'price_includes_tax': True,
'eu_reverse_charge': False,
- 'home_country': ''
+ 'home_country': '',
+ 'custom_rules': None,
}
@@ -84,6 +85,10 @@ def test_rule_update(token_client, organizer, event, taxrule):
'/api/v1/organizers/{}/events/{}/taxrules/{}/'.format(organizer.slug, event.slug, taxrule.pk),
{
"rate": "20.00",
+ "custom_rules": [
+ {"country": "AT", "address_type": "", "action": "vat", "rate": "19.00",
+ "invoice_text": {"en": "Austrian VAT applies"}}
+ ]
},
format='json'
)
@@ -111,3 +116,20 @@ def test_rule_delete_forbidden(token_client, organizer, event, taxrule):
)
assert resp.status_code == 403
assert event.tax_rules.exists()
+
+
+@pytest.mark.django_db
+def test_rule_update_invalid_rules(token_client, organizer, event, taxrule):
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/events/{}/taxrules/{}/'.format(organizer.slug, event.slug, taxrule.pk),
+ {
+ "custom_rules": [
+ {"foo": "bar"}
+ ]
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+ assert resp.data["custom_rules"][0].startswith(
+ "Your set of rules is not valid. Error message: 'country' is a required property"
+ )