From 0f9ec8becab3ce596fec91c5cd8ad4d99dd4becc Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 27 Jun 2023 13:05:54 +0200 Subject: [PATCH] API: Expose TaxRule.custom_rules (#3426) --- doc/api/resources/taxrules.rst | 17 ++++++- src/pretix/api/serializers/__init__.py | 15 ++++++ src/pretix/api/serializers/event.py | 11 ++++- src/pretix/api/serializers/order.py | 15 +----- src/pretix/base/models/tax.py | 22 +++++++++ .../schema/tax-rules-custom.schema.json | 49 +++++++++++++++++++ src/tests/api/test_taxrules.py | 24 ++++++++- 7 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 src/pretix/static/schema/tax-rules-custom.schema.json 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" + )